@stacksjs/database 0.70.203 → 0.70.205

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/dist/database.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { QB_SNAPSHOT_DIR } from "./utils";
1
2
  import { createQueryBuilder, setConfig } from "@stacksjs/query-builder";
2
3
  import { env as stacksEnv } from "@stacksjs/env";
3
4
 
@@ -39,6 +40,7 @@ export class Database {
39
40
  if (this._initialized)
40
41
  return;
41
42
  setConfig({
43
+ snapshotDir: QB_SNAPSHOT_DIR,
42
44
  dialect: this._options.driver,
43
45
  database: this._options.connection,
44
46
  verbose: this._options.verbose,
package/dist/index.d.ts CHANGED
@@ -105,6 +105,9 @@ export * from './defaults';
105
105
  // Dialect classification for the committed migration corpus, so a corpus
106
106
  // emitted for one database fails loudly before a single statement runs.
107
107
  export * from './migration-dialect';
108
+ // Model resolution for the generator: userland + framework defaults, flattened
109
+ // because bun-query-builder's loadModels reads only the top level of a dir.
110
+ export * from './model-sources';
108
111
  // Database bootstrap: probe the target, and create it over a maintenance
109
112
  // connection we open ourselves rather than through bun-query-builder, whose
110
113
  // connection string is rebuilt from process.env and cannot be redirected.
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ export { migrateRbacTables } from "./rbac-tables";
29
29
  export * from "./sql-helpers";
30
30
  export * from "./defaults";
31
31
  export * from "./migration-dialect";
32
+ export * from "./model-sources";
32
33
  export * from "./ensure-database";
33
34
  export { auditForeignKeys, findFkOrphans, getDeclaredFKs, getLiveFKs } from "./fk-audit";
34
35
  export { auditUniqueIndexes, getDeclaredUniques, getLiveUniqueIndexes } from "./unique-audit";
@@ -97,8 +97,9 @@ export function formatMigrationDialectError(audit, target, dir) {
97
97
  "Nothing was migrated, so the database is unchanged.",
98
98
  "",
99
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.",
100
+ "Regenerate them from your models:",
101
+ ` ./buddy migrate:regenerate ${target}`,
102
+ "or point DB_CONNECTION back at the database they were written for.",
102
103
  "",
103
104
  `If you know this corpus is correct, re-run with ${DIALECT_OVERRIDE_ENV}=1 to proceed anyway.`
104
105
  ].join(`
@@ -45,6 +45,27 @@ export declare function preprocessSqliteMigrations(): void;
45
45
  export declare function ensureDatabaseReady(): Promise<void>;
46
46
  /** Test seam: forget that the bootstrap already ran. */
47
47
  export declare function resetDatabaseBootstrapCache(): void;
48
+ /**
49
+ * Count how many migrations have been recorded as applied in the
50
+ * `migrations` table. Returns 0 when the table doesn't exist yet
51
+ * (fresh database, first ever migration) — bun-query-builder
52
+ * creates the table during the first `executeMigration` call.
53
+ *
54
+ * Used by {@link runDatabaseMigration} before + after the migration
55
+ * run so the caller can report `applied = afterCount - beforeCount`
56
+ * — distinguishes the "nothing to migrate" path from a real apply
57
+ * in the CLI outro (user-reported messaging gap).
58
+ */
59
+ /**
60
+ * How many migrations the target database has already recorded.
61
+ *
62
+ * Exported because regeneration must refuse to renumber a corpus that a live
63
+ * database has bookkeeping against: the migrations table keys on the FILENAME,
64
+ * so renaming files makes an applied migration look pending. One of them,
65
+ * 0000000098-revoke-legacy-long-lived-tokens.sql, is a hard
66
+ * `DELETE FROM oauth_access_tokens`, so a careless renumber re-runs a data wipe.
67
+ */
68
+ export declare function countAppliedMigrations(): Promise<number>;
48
69
  export declare function runDatabaseMigration(): Promise<Result<string, Error>>;
49
70
  /**
50
71
  * Reset the database (drop all tables)
@@ -58,6 +79,38 @@ export declare function resetDatabase(): Promise<Result<string, Error>>;
58
79
  */
59
80
  export declare function previewPendingMigrations(options?: GenerateMigrationsOptions): Promise<MigrationOperation[]>;
60
81
  export declare function generateMigrations(options?: GenerateMigrationsOptions): Promise<Result<string, Error>>;
82
+ /**
83
+ * Write generated SQL to `database/migrations/` so the runner picks it up.
84
+ * Returns the number of files written.
85
+ */
86
+ /**
87
+ * Enum types a generated corpus USES but never DEFINES.
88
+ *
89
+ * Postgres enums have to exist before a column can be typed with them, so a
90
+ * dangling reference is a guaranteed mid-migration failure rather than a
91
+ * cosmetic problem.
92
+ */
93
+ export declare function findDanglingTypeReferences(statements: string[]): string[];
94
+ /**
95
+ * Rebuild the whole migration corpus for one dialect, from the models.
96
+ *
97
+ * This is the fix for a corpus that cannot run on the configured database.
98
+ * Translating the committed SQLite DDL was never viable: the emission threw
99
+ * away varchar lengths, numeric scale and every foreign key (40 of them are
100
+ * `SELECT 1;` stubs). All of that still exists in the model definitions, so
101
+ * regenerating recovers the intent instead of guessing at it.
102
+ *
103
+ * Unlike `persistGeneratedMigrations`, which appends a diff on top of what is
104
+ * already committed, this produces a COMPLETE corpus numbered from 1 and
105
+ * replaces what is there. Callers are responsible for confirming that with the
106
+ * user, and for refusing when the target database already has migrations
107
+ * recorded against the old filenames.
108
+ */
109
+ export declare function regenerateMigrationCorpus(options?: {
110
+ dialect?: string
111
+ dir?: string
112
+ dryRun?: boolean
113
+ }): Promise<Result<RegeneratedCorpus, Error>>;
61
114
  /**
62
115
  * Group generated SQL by the migration filename style the runner already
63
116
  * uses for hand-written files: `create-<table>-table`,
@@ -85,6 +138,13 @@ export declare interface GenerateMigrationsOptions {
85
138
  applyRenames?: boolean
86
139
  fromDb?: boolean
87
140
  }
141
+ export declare interface RegeneratedCorpus {
142
+ dialect: string
143
+ models: number
144
+ files: Array<{ name: string, statements: number }>
145
+ removed: string[]
146
+ dir: string
147
+ }
88
148
  declare interface GeneratedGroup {
89
149
  label: string
90
150
  statements: string[]
@@ -1,4 +1,4 @@
1
- var {require}=import.meta;import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
1
+ var {require}=import.meta;import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { log as _log } from "@stacksjs/logging";
4
4
  const log = {
@@ -20,7 +20,7 @@ import {
20
20
  saveMigrationSnapshot,
21
21
  setConfig
22
22
  } from "@stacksjs/query-builder";
23
- import { db } from "./utils";
23
+ import { db, QB_SNAPSHOT_DIR } from "./utils";
24
24
  import {
25
25
  classifyConnectionError,
26
26
  createDatabase,
@@ -29,6 +29,7 @@ import {
29
29
  probeTargetDatabase,
30
30
  resolveConnectionTarget
31
31
  } from "./ensure-database";
32
+ import { resolveModelSources } from "./model-sources";
32
33
  import { frameworkManagedColumns, withoutManagedColumnDrops, withoutManagedColumnDropSql } from "./managed-columns";
33
34
  import { acquireMigrationLock } from "./migration-lock";
34
35
  import { env as envVars } from "@stacksjs/env";
@@ -62,7 +63,7 @@ function configureQueryBuilder() {
62
63
  setConfig({
63
64
  dialect,
64
65
  verbose: !1,
65
- snapshotDir: "storage/framework/database",
66
+ snapshotDir: QB_SNAPSHOT_DIR,
66
67
  database: {
67
68
  database: connectionConfig?.name || connectionConfig?.database || "stacks",
68
69
  host: connectionConfig?.host || "localhost",
@@ -303,7 +304,7 @@ async function restoreHiddenMigrations(hidden) {
303
304
  await fs.rename(h, original);
304
305
  } catch {}
305
306
  }
306
- async function countAppliedMigrations() {
307
+ export async function countAppliedMigrations() {
307
308
  try {
308
309
  const row = await db.selectFrom("migrations").select((eb) => eb.fn.count("id").as("n")).executeTakeFirst();
309
310
  if (!row)
@@ -494,7 +495,7 @@ export async function previewPendingMigrations(options = {}) {
494
495
  }
495
496
  function resolveSnapshotDir() {
496
497
  const configured = qbConfig?.snapshotDir;
497
- return join(process.cwd(), configured || ".qb");
498
+ return join(process.cwd(), configured || QB_SNAPSHOT_DIR);
498
499
  }
499
500
  function detectSnapshotDialectMismatch(dialect) {
500
501
  const qbDir = resolveSnapshotDir();
@@ -550,6 +551,71 @@ export async function generateMigrations(options = {}) {
550
551
  return err(handleError("Migration generation failed", error));
551
552
  }
552
553
  }
554
+ export function findDanglingTypeReferences(statements) {
555
+ const defined = new Set, referenced = new Set;
556
+ for (const statement of statements) {
557
+ for (const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))
558
+ defined.add(match[1]);
559
+ for (const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))
560
+ referenced.add(match[1]);
561
+ }
562
+ return [...referenced].filter((name) => !defined.has(name)).sort();
563
+ }
564
+ export async function regenerateMigrationCorpus(options = {}) {
565
+ try {
566
+ const dialect = options.dialect ?? getQbDialect(), dir = options.dir ?? join(process.cwd(), "database", "migrations"), sources = resolveModelSources();
567
+ if (!sources)
568
+ return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));
569
+ try {
570
+ writeFileSync(join(sources.dir, `.qb-migrations.${dialect}.json`), JSON.stringify({ plan: { dialect, tables: [] } }));
571
+ } catch {}
572
+ const snapshotPath = join(process.cwd(), ".qb", `model-snapshot.${dialect}.json`), parkedSnapshot = `${snapshotPath}.regenerating`;
573
+ let snapshotParked = !1;
574
+ if (existsSync(snapshotPath))
575
+ try {
576
+ renameSync(snapshotPath, parkedSnapshot);
577
+ snapshotParked = !0;
578
+ } catch {}
579
+ let result;
580
+ try {
581
+ result = await qbGenerateMigration(sources.dir, { dialect, dryRun: !0 });
582
+ } finally {
583
+ if (snapshotParked)
584
+ try {
585
+ renameSync(parkedSnapshot, snapshotPath);
586
+ } catch {}
587
+ }
588
+ const statements = result.sqlStatements ?? [];
589
+ if (statements.length === 0)
590
+ return err(Error(`The generator produced no SQL for dialect "${dialect}".`));
591
+ const dangling = findDanglingTypeReferences(statements);
592
+ if (dangling.length > 0)
593
+ return err(Error(`The generator emitted ${dangling.length} reference(s) to enum type(s) it never creates: ${dangling.slice(0, 5).join(", ")}${dangling.length > 5 ? `, +${dangling.length - 5} more` : ""}. Writing this corpus would fail partway through a migration. This is a bug in the migration generator, not in your models.`));
594
+ const groups = groupGeneratedStatements(statements);
595
+ let existing = [];
596
+ try {
597
+ existing = readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
598
+ } catch {}
599
+ const files = groups.map((group, index) => ({
600
+ name: `${String(index + 1).padStart(10, "0")}-${group.label}.sql`,
601
+ statements: group.statements.length
602
+ }));
603
+ if (options.dryRun)
604
+ return ok({ dialect, models: sources.models.length, files, removed: existing, dir });
605
+ mkdirSync(dir, { recursive: !0 });
606
+ for (const file of existing)
607
+ unlinkSync(join(dir, file));
608
+ groups.forEach((group, index) => {
609
+ const body = `${group.statements.map((s) => s.trim().replace(/;\s*$/, "")).join(`;
610
+ `)};
611
+ `;
612
+ writeFileSync(join(dir, files[index].name), body);
613
+ });
614
+ return ok({ dialect, models: sources.models.length, files, removed: existing, dir });
615
+ } catch (error) {
616
+ return err(handleError("Migration regeneration failed", error));
617
+ }
618
+ }
553
619
  function persistGeneratedMigrations(sqlStatements) {
554
620
  if (!sqlStatements?.length)
555
621
  return 0;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Where the flattened copy lives. Under the framework runtime directory rather
3
+ * than the OS temp dir so it is inspectable when a generation goes wrong, and
4
+ * so it lands on the same filesystem as the source.
5
+ */
6
+ export declare function modelStagingDir(): string;
7
+ /**
8
+ * Resolve models from userland and framework defaults.
9
+ *
10
+ * Returns `null` when neither root holds a model, which callers should treat as
11
+ * "nothing to generate from" rather than as an error: it is the legitimate
12
+ * state of a project that has not defined any models yet.
13
+ */
14
+ export declare function resolveModelSources(options?: { userRoot?: string, frameworkRoot?: string }): ResolvedModelSources | null;
15
+ /** Remove the staging directory. Safe to call when it was never created. */
16
+ export declare function cleanupModelStaging(): void;
17
+ export declare interface ModelSource {
18
+ file: string
19
+ name: string
20
+ origin: 'user' | 'framework'
21
+ }
22
+ export declare interface ResolvedModelSources {
23
+ dir: string
24
+ models: ModelSource[]
25
+ roots: string[]
26
+ staged: boolean
27
+ }
@@ -0,0 +1,76 @@
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { path } from "@stacksjs/path";
4
+ function collectModels(root, origin) {
5
+ if (!existsSync(root))
6
+ return [];
7
+ const out = [], walk = (dir) => {
8
+ let entries;
9
+ try {
10
+ entries = readdirSync(dir, { withFileTypes: !0 });
11
+ } catch {
12
+ return;
13
+ }
14
+ for (const entry of entries) {
15
+ const full = join(dir, entry.name);
16
+ if (entry.isDirectory()) {
17
+ walk(full);
18
+ continue;
19
+ }
20
+ if (!entry.name.endsWith(".ts"))
21
+ continue;
22
+ if (entry.name.startsWith(".") || entry.name.startsWith("index"))
23
+ continue;
24
+ out.push({ file: full, name: entry.name.replace(/\.ts$/, ""), origin });
25
+ }
26
+ };
27
+ walk(root);
28
+ return out;
29
+ }
30
+ export function modelStagingDir() {
31
+ return path.frameworkRuntimePath("model-sources");
32
+ }
33
+ function stage(models) {
34
+ const dir = modelStagingDir();
35
+ try {
36
+ rmSync(dir, { recursive: !0, force: !0 });
37
+ } catch {}
38
+ mkdirSync(dir, { recursive: !0 });
39
+ for (const model of models) {
40
+ const target = join(dir, `${model.name}.ts`);
41
+ try {
42
+ symlinkSync(model.file, target);
43
+ } catch {
44
+ try {
45
+ writeFileSync(target, readFileSync(model.file));
46
+ } catch {}
47
+ }
48
+ }
49
+ return dir;
50
+ }
51
+ export function resolveModelSources(options = {}) {
52
+ const userRoot = options.userRoot ?? path.userModelsPath(), frameworkRoot = options.frameworkRoot ?? path.frameworkPath("defaults/app/Models"), user = collectModels(userRoot, "user"), framework = collectModels(frameworkRoot, "framework");
53
+ if (user.length === 0 && framework.length === 0)
54
+ return null;
55
+ const byName = new Map;
56
+ for (const model of framework)
57
+ byName.set(model.name, model);
58
+ for (const model of user)
59
+ byName.set(model.name, model);
60
+ const models = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)), roots = [];
61
+ if (user.length > 0)
62
+ roots.push(userRoot);
63
+ if (framework.length > 0)
64
+ roots.push(frameworkRoot);
65
+ const onlyUser = framework.length === 0, allFlat = models.every((m) => basename(join(m.file, "..")) === basename(onlyUser ? userRoot : frameworkRoot));
66
+ if (roots.length === 1 && allFlat)
67
+ return { dir: roots[0], models, roots, staged: !1 };
68
+ return { dir: stage(models), models, roots, staged: !0 };
69
+ }
70
+ export function cleanupModelStaging() {
71
+ const dir = modelStagingDir();
72
+ try {
73
+ if (existsSync(dir) && lstatSync(dir).isDirectory())
74
+ rmSync(dir, { recursive: !0, force: !0 });
75
+ } catch {}
76
+ }
package/dist/utils.d.ts CHANGED
@@ -4,6 +4,19 @@ export declare function acquireDbConfigLock(): Promise<() => void>;
4
4
  export declare function initializeDbConfig(config: any): void;
5
5
  export declare function ensureDatabaseConfigLoaded(): Promise<void>;
6
6
  declare function getDb(): ReturnType<typeof createQueryBuilder>;
7
+ /**
8
+ * Update bun-query-builder configuration
9
+ */
10
+ /**
11
+ * Where the model snapshot lives, relative to the project root.
12
+ *
13
+ * Exported and referenced by every `setConfig` call rather than repeated at
14
+ * each one: `setConfig` replaces the query-builder config wholesale, so a call
15
+ * site that omits this silently reverts the snapshot to the library default
16
+ * and writes `.qb` into the project root. That has now happened twice from two
17
+ * different call sites, which is a sign the value wants one home.
18
+ */
19
+ export declare const QB_SNAPSHOT_DIR: 'storage/framework/database';
7
20
  /**
8
21
  * Lazy proxy for the query builder - connection is only made when first used.
9
22
  * This is the main entry point for database operations.
package/dist/utils.js CHANGED
@@ -87,13 +87,14 @@ function getDbConfig() {
87
87
  }
88
88
  return { database: ":memory:" };
89
89
  }
90
+ export const QB_SNAPSHOT_DIR = "storage/framework/database";
90
91
  function updateQueryBuilderConfig() {
91
92
  const dialect = getDialect(), dbConfigForQb = getDbConfig();
92
93
  setConfig({
93
94
  dialect,
94
95
  database: dbConfigForQb,
95
96
  verbose: getEnv() !== "production",
96
- snapshotDir: "storage/framework/database",
97
+ snapshotDir: QB_SNAPSHOT_DIR,
97
98
  timestamps: {
98
99
  createdAt: "created_at",
99
100
  updatedAt: "updated_at",
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.203",
5
+ "version": "0.70.205",
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.203",
64
- "@stacksjs/config": "0.70.203",
65
- "@stacksjs/logging": "0.70.203",
66
- "@stacksjs/router": "0.70.203",
63
+ "@stacksjs/cli": "0.70.205",
64
+ "@stacksjs/config": "0.70.205",
65
+ "@stacksjs/logging": "0.70.205",
66
+ "@stacksjs/router": "0.70.205",
67
67
  "better-dx": "^0.2.17",
68
- "@stacksjs/path": "0.70.203",
69
- "@stacksjs/query-builder": "0.70.203",
70
- "@stacksjs/storage": "0.70.203",
71
- "@stacksjs/strings": "0.70.203",
72
- "@stacksjs/utils": "0.70.203"
68
+ "@stacksjs/path": "0.70.205",
69
+ "@stacksjs/query-builder": "0.70.205",
70
+ "@stacksjs/storage": "0.70.205",
71
+ "@stacksjs/strings": "0.70.205",
72
+ "@stacksjs/utils": "0.70.205"
73
73
  }
74
74
  }