@stacksjs/database 0.70.200 → 0.70.202

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,101 @@
1
+ export declare function isValidDatabaseIdentifier(name: string): boolean;
2
+ /** Quote an identifier for the dialect. Only ever called after validation. */
3
+ export declare function quoteIdentifier(dialect: 'postgres' | 'mysql', name: string): string;
4
+ /**
5
+ * Classify a driver error. The shapes below were captured from Bun's real
6
+ * Postgres client, not inferred: a missing database surfaces
7
+ * `errno: "3D000"` (a STRING) with `code: "ERR_POSTGRES_SERVER_ERROR"`, a
8
+ * missing role `errno: "28000"`, and a refused socket carries no errno at all
9
+ * but `code: "ERR_POSTGRES_CONNECTION_REFUSED"`. MySQL reports numeric errnos,
10
+ * so both types are accepted.
11
+ */
12
+ export declare function classifyConnectionError(error: unknown): ConnectionFailureKind;
13
+ /**
14
+ * Resolve the connection the migration runner will actually use.
15
+ *
16
+ * Precedence mirrors bun-query-builder's `createConnectionString()` exactly
17
+ * (env first, config second), because bqb is what opens the real connection.
18
+ * A config-first resolver here would let us create `foo` while bqb migrates
19
+ * `bar`.
20
+ *
21
+ * Returns `null` when there is nothing to bootstrap: SQLite creates its file
22
+ * on open, DynamoDB has no SQL catalog, and an unknown driver is not ours to
23
+ * guess at.
24
+ */
25
+ export declare function resolveConnectionTarget(envProxy?: Record<string, any>): ConnectionTarget | null;
26
+ /** Build a connection URL for an arbitrary database on the same server. */
27
+ export declare function buildConnectionUrl(target: ConnectionTarget, database: string): string;
28
+ /**
29
+ * Probe the TARGET database directly.
30
+ *
31
+ * Order matters: we do not open a maintenance connection first. On a
32
+ * locked-down managed instance the app user often cannot touch `postgres` or
33
+ * `mysql` at all, so leading with maintenance would fail even when the target
34
+ * exists and everything would otherwise have worked.
35
+ */
36
+ export declare function probeTargetDatabase(target: ConnectionTarget, deps?: EnsureDatabaseDeps): Promise<ProbeResult>;
37
+ /**
38
+ * Issue `CREATE DATABASE` from a maintenance connection.
39
+ *
40
+ * Ownership note: we authenticate as the same user the app will use, so the
41
+ * new database is owned by that user and no explicit OWNER clause is needed.
42
+ * That matters on PG15+, where a database owned by someone else leaves the app
43
+ * role unable to create tables in `public`.
44
+ */
45
+ export declare function createDatabase(target: ConnectionTarget, deps?: EnsureDatabaseDeps): Promise<CreateDatabaseResult>;
46
+ /**
47
+ * Best-effort check for whether the connected role may create databases.
48
+ * Used to avoid asking a question whose "yes" is guaranteed to fail.
49
+ * Returns `null` when the answer cannot be determined, which callers must
50
+ * treat as "go ahead and try".
51
+ */
52
+ export declare function canCreateDatabases(target: ConnectionTarget, deps?: EnsureDatabaseDeps): Promise<boolean | null>;
53
+ /** Human-readable, copy-pasteable remediation for a failed or declined create. */
54
+ export declare function manualCreateHint(target: ConnectionTarget): string;
55
+ /** One-line description of what we are pointed at, for prompts and errors. */
56
+ export declare function describeTarget(target: ConnectionTarget): string;
57
+ /**
58
+ * A connection we can issue maintenance DDL on. Structurally satisfied by
59
+ * Bun's `SQL` instance; declared here so tests can substitute a fake.
60
+ */
61
+ export declare interface MaintenanceClient {
62
+ unsafe: (sql: string) => any
63
+ close: () => any
64
+ }
65
+ export declare interface ConnectionTarget {
66
+ dialect: 'postgres' | 'mysql'
67
+ driver: string
68
+ database: string
69
+ host: string
70
+ port: number
71
+ username: string
72
+ password: string
73
+ maintenanceCandidates: string[]
74
+ }
75
+ export declare interface ProbeResult {
76
+ ok: boolean
77
+ kind?: ConnectionFailureKind
78
+ error?: unknown
79
+ }
80
+ export declare interface EnsureDatabaseDeps {
81
+ connect?: ConnectFn
82
+ timeoutMs?: number
83
+ }
84
+ export declare interface CreateDatabaseResult {
85
+ created: boolean
86
+ kind?: ConnectionFailureKind
87
+ error?: unknown
88
+ via?: string
89
+ }
90
+ export type ConnectFn = (url: string) => MaintenanceClient;
91
+ /**
92
+ * Why a connection attempt failed. Callers branch on this rather than on
93
+ * error message text, which differs per server version and locale.
94
+ */
95
+ export type ConnectionFailureKind = | 'missing-database'
96
+ | 'missing-role'
97
+ | 'auth-failed'
98
+ | 'server-unreachable'
99
+ | 'permission-denied'
100
+ | 'timeout'
101
+ | 'unknown';
@@ -0,0 +1,145 @@
1
+ import process from "node:process";
2
+ import { SQL } from "bun";
3
+ import { env as envVars } from "@stacksjs/env";
4
+ import { DB_HOST_DEFAULT, DB_NAMES, DB_PORTS, DB_USERS, getConnectionDefaults } from "./defaults";
5
+ const DEFAULT_TIMEOUT_MS = 1e4, UNSAFE_IDENTIFIER_CHARS = /["'`\\;\u0000\n\r]/, MAX_IDENTIFIER_LENGTH = 63;
6
+ export function isValidDatabaseIdentifier(name) {
7
+ if (!name || name.length > MAX_IDENTIFIER_LENGTH)
8
+ return !1;
9
+ return !UNSAFE_IDENTIFIER_CHARS.test(name);
10
+ }
11
+ export function quoteIdentifier(dialect, name) {
12
+ return dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
13
+ }
14
+ function stripWrappingQuotes(value) {
15
+ return value.replace(/^['"]|['"]$/g, "");
16
+ }
17
+ export function classifyConnectionError(error) {
18
+ const e = error, errno = e?.errno, code = typeof e?.code === "string" ? e.code : "", message = typeof e?.message === "string" ? e.message : String(error ?? ""), sqlState = typeof errno === "string" ? errno.toUpperCase() : "", mysqlErrno = typeof errno === "number" ? errno : Number.NaN;
19
+ if (sqlState === "3D000")
20
+ return "missing-database";
21
+ if (sqlState === "28000" || sqlState === "28P01")
22
+ return sqlState === "28P01" ? "auth-failed" : "missing-role";
23
+ if (sqlState === "42501")
24
+ return "permission-denied";
25
+ if (mysqlErrno === 1049)
26
+ return "missing-database";
27
+ if (mysqlErrno === 1045)
28
+ return "auth-failed";
29
+ if (mysqlErrno === 1044)
30
+ return "permission-denied";
31
+ if (mysqlErrno === 2002 || mysqlErrno === 2003)
32
+ return "server-unreachable";
33
+ if (code.includes("CONNECTION_REFUSED") || code.includes("CONNECTION_CLOSED") || code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "EHOSTUNREACH")
34
+ return "server-unreachable";
35
+ if (code === "ETIMEDOUT" || code.includes("TIMEOUT"))
36
+ return "timeout";
37
+ if (/database .* does not exist|unknown database/i.test(message))
38
+ return "missing-database";
39
+ if (/role .* does not exist|user .* does not exist/i.test(message))
40
+ return "missing-role";
41
+ if (/password authentication failed|access denied for user/i.test(message))
42
+ return "auth-failed";
43
+ if (/permission denied|insufficient privilege/i.test(message))
44
+ return "permission-denied";
45
+ if (/econnrefused|connection refused|can'?t connect/i.test(message))
46
+ return "server-unreachable";
47
+ return "unknown";
48
+ }
49
+ export function resolveConnectionTarget(envProxy = envVars) {
50
+ const driver = String(envProxy.DB_CONNECTION || "sqlite");
51
+ if (driver !== "postgres" && driver !== "mysql" && driver !== "singlestore")
52
+ return null;
53
+ const dialect = driver === "postgres" ? "postgres" : "mysql", defaults = getConnectionDefaults(dialect, envProxy), database = stripWrappingQuotes(String(envProxy.DB_DATABASE || defaults.database || DB_NAMES.default)), host = String(envProxy.DB_HOST || defaults.host || DB_HOST_DEFAULT), port = Number(envProxy.DB_PORT || defaults.port || DB_PORTS[dialect]), username = String(envProxy.DB_USERNAME || defaults.username || DB_USERS[dialect]), password = String(envProxy.DB_PASSWORD ?? defaults.password ?? "");
54
+ return {
55
+ dialect,
56
+ driver,
57
+ database,
58
+ host,
59
+ port,
60
+ username,
61
+ password,
62
+ maintenanceCandidates: dialect === "postgres" ? ["postgres", "template1"] : ["information_schema", "mysql"]
63
+ };
64
+ }
65
+ export function buildConnectionUrl(target, database) {
66
+ const scheme = target.dialect === "postgres" ? "postgres" : "mysql", auth = target.password ? `${encodeURIComponent(target.username)}:${encodeURIComponent(target.password)}` : encodeURIComponent(target.username), sslEnv = process.env.DB_SSL, ssl = sslEnv === "true" || sslEnv === "1" ? "?ssl=true" : "";
67
+ return `${scheme}://${auth}@${target.host}:${target.port}/${encodeURIComponent(database)}${ssl}`;
68
+ }
69
+ function defaultConnect(url) {
70
+ return new SQL(url);
71
+ }
72
+ async function withConnection(target, database, deps, work) {
73
+ const connect = deps.connect ?? defaultConnect, timeoutMs = deps.timeoutMs ?? Number(process.env.DB_PREFLIGHT_TIMEOUT_MS || DEFAULT_TIMEOUT_MS), client = connect(buildConnectionUrl(target, database));
74
+ let timer;
75
+ try {
76
+ const timeout = new Promise((_, reject) => {
77
+ timer = setTimeout(() => {
78
+ const e = Error(`Timed out after ${timeoutMs}ms connecting to ${target.dialect} at ${target.host}:${target.port}`);
79
+ e.code = "ETIMEDOUT";
80
+ reject(e);
81
+ }, timeoutMs);
82
+ });
83
+ return await Promise.race([work(client), timeout]);
84
+ } finally {
85
+ if (timer)
86
+ clearTimeout(timer);
87
+ try {
88
+ await client.close();
89
+ } catch {}
90
+ }
91
+ }
92
+ export async function probeTargetDatabase(target, deps = {}) {
93
+ try {
94
+ await withConnection(target, target.database, deps, async (client) => client.unsafe("select 1"));
95
+ return { ok: !0 };
96
+ } catch (error) {
97
+ return { ok: !1, kind: classifyConnectionError(error), error };
98
+ }
99
+ }
100
+ export async function createDatabase(target, deps = {}) {
101
+ if (!isValidDatabaseIdentifier(target.database))
102
+ return {
103
+ created: !1,
104
+ kind: "unknown",
105
+ error: Error(`Refusing to create a database named ${JSON.stringify(target.database)}. Database names must be 1 to 63 characters and may not contain quotes, backslashes, semicolons, or newlines.`)
106
+ };
107
+ const identifier = quoteIdentifier(target.dialect, target.database), sql = target.dialect === "mysql" ? `CREATE DATABASE IF NOT EXISTS ${identifier}` : `CREATE DATABASE ${identifier}`;
108
+ let lastError, lastKind = "unknown";
109
+ for (const candidate of target.maintenanceCandidates)
110
+ try {
111
+ await withConnection(target, candidate, deps, async (client) => client.unsafe(sql));
112
+ return { created: !0, via: candidate };
113
+ } catch (error) {
114
+ const kind = classifyConnectionError(error), message = error?.message ?? "", errno = error?.errno;
115
+ if (errno === "42P04" || errno === 1007 || /already exists|database exists/i.test(String(message)))
116
+ return { created: !1, via: candidate };
117
+ lastError = error;
118
+ lastKind = kind;
119
+ if (kind === "permission-denied" || kind === "auth-failed" || kind === "missing-role")
120
+ break;
121
+ }
122
+ return { created: !1, kind: lastKind, error: lastError };
123
+ }
124
+ export async function canCreateDatabases(target, deps = {}) {
125
+ if (target.dialect !== "postgres")
126
+ return null;
127
+ for (const candidate of target.maintenanceCandidates)
128
+ try {
129
+ return await withConnection(target, candidate, deps, async (client) => {
130
+ const rows = await client.unsafe("select rolcreatedb, rolsuper from pg_roles where rolname = current_user"), row = Array.isArray(rows) ? rows[0] : void 0;
131
+ if (!row)
132
+ return null;
133
+ return Boolean(row.rolcreatedb || row.rolsuper);
134
+ });
135
+ } catch {}
136
+ return null;
137
+ }
138
+ export function manualCreateHint(target) {
139
+ if (target.dialect === "postgres")
140
+ return `createdb -h ${target.host} -p ${target.port} -U ${target.username} ${target.database}`;
141
+ return `mysql -h ${target.host} -P ${target.port} -u ${target.username} -e "CREATE DATABASE \\\`${target.database}\\\`"`;
142
+ }
143
+ export function describeTarget(target) {
144
+ return `the ${target.driver} connection (${target.host}:${target.port}, user "${target.username}")`;
145
+ }
package/dist/index.d.ts CHANGED
@@ -102,6 +102,13 @@ export { migrateRbacTables } from './rbac-tables';
102
102
  // SQL dialect helpers & connection defaults
103
103
  export * from './sql-helpers';
104
104
  export * from './defaults';
105
+ // Dialect classification for the committed migration corpus, so a corpus
106
+ // emitted for one database fails loudly before a single statement runs.
107
+ export * from './migration-dialect';
108
+ // Database bootstrap: probe the target, and create it over a maintenance
109
+ // connection we open ourselves rather than through bun-query-builder, whose
110
+ // connection string is rebuilt from process.env and cannot be redirected.
111
+ export * from './ensure-database';
105
112
  // Foreign-key audit (stacksjs/stacks#1916) — compare declared
106
113
  // `belongsTo` relationships against live FKs.
107
114
  export { auditForeignKeys, findFkOrphans, getDeclaredFKs, getLiveFKs } from './fk-audit';
package/dist/index.js CHANGED
@@ -28,6 +28,8 @@ export { migrateNotificationTables } from "./notification-tables";
28
28
  export { migrateRbacTables } from "./rbac-tables";
29
29
  export * from "./sql-helpers";
30
30
  export * from "./defaults";
31
+ export * from "./migration-dialect";
32
+ export * from "./ensure-database";
31
33
  export { auditForeignKeys, findFkOrphans, getDeclaredFKs, getLiveFKs } from "./fk-audit";
32
34
  export { auditUniqueIndexes, getDeclaredUniques, getLiveUniqueIndexes } from "./unique-audit";
33
35
  export {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Blank out comments and quoted text, preserving offsets so reported line
3
+ * numbers still point at the real source line.
4
+ *
5
+ * This is the whole reason `migrate:switch` was reporting 40 foreign-key
6
+ * migrations that do not exist: it regex-matched raw file content, so the
7
+ * words inside `-- Skipped: SQLite does not support ALTER TABLE ADD
8
+ * CONSTRAINT` counted as an ALTER TABLE ADD CONSTRAINT.
9
+ */
10
+ export declare function stripSqlNoise(sql: string): string;
11
+ /** Find dialect-exclusive markers in one file's SQL. */
12
+ export declare function classifyMigrationSql(sql: string, file: string): DialectMarker[];
13
+ /**
14
+ * Audit a whole migration directory against the dialect it is about to run on.
15
+ *
16
+ * `inferred` is only set when every marker found agrees, so a genuinely mixed
17
+ * or portable corpus reports `null` rather than a misleading guess.
18
+ */
19
+ export declare function auditMigrationCorpus(options: {
20
+ dir: string
21
+ target: MigrationDialect
22
+ }): MigrationCorpusAudit;
23
+ /**
24
+ * The user-facing explanation. Long on purpose: this replaces a raw
25
+ * `syntax error at or near "AUTOINCREMENT"` that told the user nothing about
26
+ * why their brand new project could not migrate.
27
+ */
28
+ export declare function formatMigrationDialectError(audit: MigrationCorpusAudit, target: MigrationDialect, dir: string): string;
29
+ /** Environment escape hatch, for an operator whose corpus is genuinely fine. */
30
+ export declare const DIALECT_OVERRIDE_ENV: 'STACKS_ALLOW_DIALECT_MISMATCH';
31
+ export declare interface DialectMarker {
32
+ dialect: MigrationDialect
33
+ marker: string
34
+ file: string
35
+ line: number
36
+ snippet: string
37
+ }
38
+ export declare interface MigrationCorpusAudit {
39
+ total: number
40
+ inferred: MigrationDialect | null
41
+ incompatible: DialectMarker[]
42
+ empty: boolean
43
+ }
44
+ export type MigrationDialect = 'sqlite' | 'postgres' | 'mysql';
@@ -0,0 +1,106 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const MARKERS = [
4
+ { dialect: "sqlite", pattern: /\bAUTOINCREMENT\b/i, label: "AUTOINCREMENT" },
5
+ { dialect: "sqlite", pattern: /\bWITHOUT\s+ROWID\b/i, label: "WITHOUT ROWID" },
6
+ { dialect: "sqlite", pattern: /\bPRAGMA\b/i, label: "PRAGMA" },
7
+ { dialect: "mysql", pattern: /\bAUTO_INCREMENT\b/i, label: "AUTO_INCREMENT" },
8
+ { dialect: "mysql", pattern: /\bENGINE\s*=/i, label: "ENGINE=" },
9
+ { dialect: "postgres", pattern: /\bBIGSERIAL\b/i, label: "BIGSERIAL" },
10
+ { dialect: "postgres", pattern: /\bSERIAL\b/i, label: "SERIAL" },
11
+ { dialect: "postgres", pattern: /\bCREATE\s+TYPE\b/i, label: "CREATE TYPE" },
12
+ { dialect: "postgres", pattern: /\bGENERATED\s+(?:ALWAYS|BY\s+DEFAULT)\s+AS\s+IDENTITY\b/i, label: "GENERATED AS IDENTITY" }
13
+ ];
14
+ export function stripSqlNoise(sql) {
15
+ let out = "", i = 0;
16
+ const blank = (text) => text.replace(/[^\n]/g, " ");
17
+ while (i < sql.length) {
18
+ const rest = sql.slice(i), line = rest.match(/^--[^\n]*/);
19
+ if (line) {
20
+ out += blank(line[0]);
21
+ i += line[0].length;
22
+ continue;
23
+ }
24
+ if (rest.startsWith("/*")) {
25
+ const end = rest.indexOf("*/"), chunk = end === -1 ? rest : rest.slice(0, end + 2);
26
+ out += blank(chunk);
27
+ i += chunk.length;
28
+ continue;
29
+ }
30
+ const quote = rest[0];
31
+ if (quote === '"' || quote === "'" || quote === "`") {
32
+ let j = 1;
33
+ while (j < rest.length && rest[j] !== quote)
34
+ j++;
35
+ const chunk = rest.slice(0, Math.min(j + 1, rest.length));
36
+ out += blank(chunk);
37
+ i += chunk.length;
38
+ continue;
39
+ }
40
+ out += sql[i];
41
+ i += 1;
42
+ }
43
+ return out;
44
+ }
45
+ export function classifyMigrationSql(sql, file) {
46
+ const lines = stripSqlNoise(sql).split(`
47
+ `), rawLines = sql.split(`
48
+ `), found = [];
49
+ for (let index = 0;index < lines.length; index++)
50
+ for (const { dialect, pattern, label } of MARKERS)
51
+ if (pattern.test(lines[index] ?? ""))
52
+ found.push({
53
+ dialect,
54
+ marker: label,
55
+ file,
56
+ line: index + 1,
57
+ snippet: (rawLines[index] ?? "").trim().slice(0, 120)
58
+ });
59
+ return found;
60
+ }
61
+ export function auditMigrationCorpus(options) {
62
+ const { dir, target } = options;
63
+ if (!existsSync(dir))
64
+ return { total: 0, inferred: null, incompatible: [], empty: !0 };
65
+ let files;
66
+ try {
67
+ files = readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
68
+ } catch {
69
+ return { total: 0, inferred: null, incompatible: [], empty: !0 };
70
+ }
71
+ const all = [];
72
+ for (const file of files) {
73
+ let sql;
74
+ try {
75
+ sql = readFileSync(join(dir, file), "utf8");
76
+ } catch {
77
+ continue;
78
+ }
79
+ all.push(...classifyMigrationSql(sql, file));
80
+ }
81
+ const dialects = new Set(all.map((m) => m.dialect));
82
+ return {
83
+ total: files.length,
84
+ inferred: dialects.size === 1 ? [...dialects][0] ?? null : null,
85
+ incompatible: all.filter((m) => m.dialect !== target),
86
+ empty: files.length === 0
87
+ };
88
+ }
89
+ export const DIALECT_OVERRIDE_ENV = "STACKS_ALLOW_DIALECT_MISMATCH";
90
+ export function formatMigrationDialectError(audit, target, dir) {
91
+ const affected = new Set(audit.incompatible.map((m) => m.file)), sample = audit.incompatible.slice(0, 3), inferred = audit.inferred ? `${audit.inferred}-flavoured` : "written for a different database";
92
+ return [
93
+ `The migration files in ${dir} are ${inferred} and cannot run on ${target}.`,
94
+ `${affected.size} of ${audit.total} files use SQL that ${target} does not accept. For example:`,
95
+ ...sample.map((m) => ` ${m.file}:${m.line} ${m.marker}`),
96
+ "",
97
+ "Nothing was migrated, so the database is unchanged.",
98
+ "",
99
+ "Stacks ships one set of migration files, and they are emitted for a single database.",
100
+ `To use ${target}, regenerate them from your models against ${target}, or point`,
101
+ "DB_CONNECTION back at the database they were written for.",
102
+ "",
103
+ `If you know this corpus is correct, re-run with ${DIALECT_OVERRIDE_ENV}=1 to proceed anyway.`
104
+ ].join(`
105
+ `);
106
+ }
@@ -30,6 +30,21 @@ export type { MigrationResult as MigrationResultType };
30
30
  * directory clean and prevents future runs from re-discovering it.
31
31
  */
32
32
  export declare function preprocessSqliteMigrations(): void;
33
+ /**
34
+ * Public bootstrap entry point.
35
+ *
36
+ * Call this before ANY other database work in a command. `migrate` reaches the
37
+ * server from several independent places (auth tables, notification tables,
38
+ * RBAC tables, the numbered migrations), and each one used to discover a
39
+ * missing database on its own and report it separately, which is how a single
40
+ * root cause turned into twenty lines of near-identical errors.
41
+ *
42
+ * Throws with one actionable message. Only a success is memoised, so a
43
+ * transient failure can be retried in the same process.
44
+ */
45
+ export declare function ensureDatabaseReady(): Promise<void>;
46
+ /** Test seam: forget that the bootstrap already ran. */
47
+ export declare function resetDatabaseBootstrapCache(): void;
33
48
  export declare function runDatabaseMigration(): Promise<Result<string, Error>>;
34
49
  /**
35
50
  * Reset the database (drop all tables)
@@ -16,10 +16,19 @@ import {
16
16
  generateMigration as qbGenerateMigration,
17
17
  resetConnection,
18
18
  resetDatabase as qbResetDatabase,
19
+ config as qbConfig,
19
20
  saveMigrationSnapshot,
20
21
  setConfig
21
22
  } from "@stacksjs/query-builder";
22
23
  import { db } from "./utils";
24
+ import {
25
+ classifyConnectionError,
26
+ createDatabase,
27
+ describeTarget,
28
+ manualCreateHint,
29
+ probeTargetDatabase,
30
+ resolveConnectionTarget
31
+ } from "./ensure-database";
23
32
  import { frameworkManagedColumns, withoutManagedColumnDrops, withoutManagedColumnDropSql } from "./managed-columns";
24
33
  import { acquireMigrationLock } from "./migration-lock";
25
34
  import { env as envVars } from "@stacksjs/env";
@@ -53,6 +62,7 @@ function configureQueryBuilder() {
53
62
  setConfig({
54
63
  dialect,
55
64
  verbose: !1,
65
+ snapshotDir: "storage/framework/database",
56
66
  database: {
57
67
  database: connectionConfig?.name || connectionConfig?.database || "stacks",
58
68
  host: connectionConfig?.host || "localhost",
@@ -201,50 +211,59 @@ export function preprocessSqliteMigrations() {
201
211
  log.debug(`[migration] Could not record dropped migrations as executed: ${e}`);
202
212
  }
203
213
  }
214
+ function mayCreateMissingDatabase() {
215
+ const signal = process.env.STACKS_CREATE_DATABASE;
216
+ if (signal === "1")
217
+ return !0;
218
+ if (signal === "0")
219
+ return !1;
220
+ const policy = String(process.env.DB_CREATE_DATABASE || "").toLowerCase();
221
+ return !(policy === "never" || policy === "false" || policy === "0");
222
+ }
223
+ function describeProbeFailure(target, kind, error) {
224
+ const where = describeTarget(target), detail = error instanceof Error ? error.message : String(error ?? "");
225
+ switch (kind) {
226
+ case "missing-role":
227
+ return `The user "${target.username}" does not exist on ${where}. Set DB_USERNAME to a role that exists, or create it with: createuser -s ${target.username}`;
228
+ case "auth-failed":
229
+ return `Authentication failed for user "${target.username}" on ${where}. Check DB_USERNAME and DB_PASSWORD.`;
230
+ case "server-unreachable":
231
+ return `Could not reach the database server at ${target.host}:${target.port}. Check that it is running and that DB_HOST and DB_PORT are correct.`;
232
+ case "timeout":
233
+ return `Timed out connecting to the database server at ${target.host}:${target.port}. ${detail}`;
234
+ case "permission-denied":
235
+ return `The user "${target.username}" is not allowed to connect to "${target.database}" on ${where}.`;
236
+ default:
237
+ return `Could not connect to the database "${target.database}" on ${where}. ${detail}`;
238
+ }
239
+ }
204
240
  async function ensureDatabaseExists() {
205
- const dialect = getDialect();
206
- if (dialect === "sqlite")
241
+ const target = resolveConnectionTarget();
242
+ if (!target)
207
243
  return;
208
- const connectionConfig = dbConfig.connections[dialect], dbName = (connectionConfig?.name || "stacks").replace(/['"]/g, ""), host = connectionConfig?.host || "localhost", port = connectionConfig?.port || (dialect === "postgres" ? 5432 : 3306), username = connectionConfig?.username || (dialect === "postgres" ? process.env.USER || "postgres" : "root"), password = connectionConfig?.password || "", adminDatabase = dialect === "postgres" ? "postgres" : "mysql";
209
- try {
210
- setConfig({
211
- dialect,
212
- database: {
213
- database: adminDatabase,
214
- host,
215
- port,
216
- username,
217
- password
218
- }
219
- });
220
- resetConnection();
221
- const adminDb = createQueryBuilder();
222
- if (dialect === "postgres")
223
- try {
224
- await adminDb.unsafe(`CREATE DATABASE "${dbName}"`);
225
- log.info(`Created database "${dbName}"`);
226
- } catch (e) {
227
- if (e?.message?.includes("already exists") || e?.errno === "42P04")
228
- log.info(`Database "${dbName}" already exists`);
229
- else
230
- throw e;
231
- }
232
- else if (dialect === "mysql")
233
- try {
234
- await adminDb.unsafe(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``);
235
- log.info(`Ensured database "${dbName}" exists`);
236
- } catch (e) {
237
- if (e?.message?.includes("database exists"))
238
- log.info(`Database "${dbName}" already exists`);
239
- else
240
- throw e;
241
- }
242
- resetConnection();
243
- } catch (error) {
244
- log.warn(`Could not auto-create database "${dbName}": ${error?.message || error}`);
245
- log.info("If the database already exists, this warning can be ignored.");
246
- resetConnection();
247
- }
244
+ const probe = await probeTargetDatabase(target);
245
+ if (probe.ok)
246
+ return;
247
+ if (probe.kind !== "missing-database")
248
+ throw Error(describeProbeFailure(target, probe.kind, probe.error));
249
+ if (!mayCreateMissingDatabase())
250
+ throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)}. Create it with: ${manualCreateHint(target)}`);
251
+ const result = await createDatabase(target);
252
+ if (!result.created && result.error)
253
+ throw Error(`The database "${target.database}" does not exist on ${describeTarget(target)} and it could not be created automatically. ${describeProbeFailure(target, result.kind, result.error)}
254
+ Create it yourself with: ${manualCreateHint(target)}`);
255
+ if (result.created)
256
+ log.success(`Created database "${target.database}" on ${target.host}:${target.port}`);
257
+ }
258
+ let databaseBootstrapped = !1;
259
+ export async function ensureDatabaseReady() {
260
+ if (databaseBootstrapped)
261
+ return;
262
+ await ensureDatabaseExists();
263
+ databaseBootstrapped = !0;
264
+ }
265
+ export function resetDatabaseBootstrapCache() {
266
+ databaseBootstrapped = !1;
248
267
  }
249
268
  async function hideDisabledFeatureMigrations() {
250
269
  const hidden = [];
@@ -325,7 +344,7 @@ function idempotentSql(sql) {
325
344
  `;
326
345
  }
327
346
  function makeMigrationsIdempotent() {
328
- const migrationsDir = join(process.cwd(), "database", "migrations");
347
+ const rewritten = [], migrationsDir = join(process.cwd(), "database", "migrations");
329
348
  let files;
330
349
  try {
331
350
  files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql"));
@@ -346,16 +365,18 @@ function makeMigrationsIdempotent() {
346
365
  if (next !== sql)
347
366
  try {
348
367
  writeFileSync(p, next);
349
- log.debug(`[migration] made idempotent: ${f}`);
368
+ rewritten.push(f);
350
369
  } catch {}
351
370
  }
371
+ if (rewritten.length > 0)
372
+ log.warn(`[migration] Rewrote ${rewritten.length} migration file(s) on disk to be idempotent: ${rewritten.slice(0, 3).join(", ")}${rewritten.length > 3 ? `, +${rewritten.length - 3} more` : ""}. These files are tracked in git, so this shows up as a working-tree change.`);
352
373
  }
353
374
  export async function runDatabaseMigration() {
354
375
  const startedAt = Date.now(), hidden = await hideDisabledFeatureMigrations();
355
376
  let lockHandle = null;
356
377
  try {
357
378
  log.debug("Migrating database...");
358
- await ensureDatabaseExists();
379
+ await ensureDatabaseReady();
359
380
  configureQueryBuilder();
360
381
  const dialect = getDialect(), lockDb = dialect === "sqlite" ? null : createQueryBuilder();
361
382
  lockHandle = await acquireMigrationLock(dialect, lockDb);
@@ -395,10 +416,14 @@ const FRAMEWORK_TABLES = [
395
416
  ];
396
417
  export async function resetDatabase() {
397
418
  try {
419
+ await ensureDatabaseReady();
398
420
  configureQueryBuilder();
399
421
  const modelsDir = path.userModelsPath(), dialect = getDialect();
400
422
  await dropFrameworkTables(dialect);
401
- await qbResetDatabase(modelsDir, { dialect });
423
+ if (existsSync(modelsDir))
424
+ await qbResetDatabase(modelsDir, { dialect });
425
+ else
426
+ log.debug(`No models directory at ${modelsDir}; skipping model table drops.`);
402
427
  return ok("All tables dropped successfully!");
403
428
  } catch (error) {
404
429
  return err(handleError("Database reset failed", error));
@@ -430,6 +455,9 @@ async function dropFrameworkTables(dialect) {
430
455
  await db.unsafe(dropSql).execute();
431
456
  log.info(`Dropped framework table: ${tableName}`);
432
457
  } catch (error) {
458
+ const kind = classifyConnectionError(error);
459
+ if (kind === "missing-database" || kind === "missing-role" || kind === "auth-failed" || kind === "server-unreachable" || kind === "timeout")
460
+ throw error;
433
461
  log.warn(`Could not drop table ${tableName}: ${error instanceof Error ? error.message : String(error)}`);
434
462
  }
435
463
  if (dialect === "mysql")
@@ -464,8 +492,12 @@ export async function previewPendingMigrations(options = {}) {
464
492
  return [];
465
493
  }
466
494
  }
495
+ function resolveSnapshotDir() {
496
+ const configured = qbConfig?.snapshotDir;
497
+ return join(process.cwd(), configured || ".qb");
498
+ }
467
499
  function detectSnapshotDialectMismatch(dialect) {
468
- const qbDir = join(process.cwd(), ".qb");
500
+ const qbDir = resolveSnapshotDir();
469
501
  let files;
470
502
  try {
471
503
  files = readdirSync(qbDir);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/database",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.200",
5
+ "version": "0.70.202",
6
6
  "description": "The Stacks database integration.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -60,15 +60,15 @@
60
60
  "dynamodb-tooling": "^0.3.2"
61
61
  },
62
62
  "devDependencies": {
63
- "@stacksjs/cli": "0.70.200",
64
- "@stacksjs/config": "0.70.200",
65
- "@stacksjs/logging": "0.70.200",
66
- "@stacksjs/router": "0.70.200",
63
+ "@stacksjs/cli": "0.70.202",
64
+ "@stacksjs/config": "0.70.202",
65
+ "@stacksjs/logging": "0.70.202",
66
+ "@stacksjs/router": "0.70.202",
67
67
  "better-dx": "^0.2.17",
68
- "@stacksjs/path": "0.70.200",
69
- "@stacksjs/query-builder": "0.70.200",
70
- "@stacksjs/storage": "0.70.200",
71
- "@stacksjs/strings": "0.70.200",
72
- "@stacksjs/utils": "0.70.200"
68
+ "@stacksjs/path": "0.70.202",
69
+ "@stacksjs/query-builder": "0.70.202",
70
+ "@stacksjs/storage": "0.70.202",
71
+ "@stacksjs/strings": "0.70.202",
72
+ "@stacksjs/utils": "0.70.202"
73
73
  }
74
74
  }