@stacksjs/database 0.70.240 → 0.70.243

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.
@@ -42,6 +42,20 @@ export declare function prepareMigrationModelsDir(): { modelsDir: string, skip:
42
42
  * applied while its first column had never been created.
43
43
  */
44
44
  export declare function sqlStatementsOf(content: string): string[];
45
+ /**
46
+ * Make a Postgres `CREATE TYPE … AS ENUM` statement safe to run twice.
47
+ *
48
+ * `CREATE TYPE` has no `IF NOT EXISTS`, so a corpus containing one can only be
49
+ * applied to a database that has never seen it. Every other way a migration run
50
+ * can be interrupted - a partially recorded ledger, a schema restored from a
51
+ * dump, a database built before the ledger existed - leaves `buddy migrate`
52
+ * dead on the first enum it meets, with an error naming a type that is already
53
+ * exactly right.
54
+ *
55
+ * The guard is a `DO` block catching `duplicate_object`, which needs a
56
+ * dollar-quoted body - hence the splitter above having to understand one.
57
+ */
58
+ export declare function guardPostgresEnumTypes(sql: string): string;
45
59
  export declare function preprocessSqliteMigrations(): void;
46
60
  /**
47
61
  * Public bootstrap entry point.
@@ -131,6 +145,21 @@ export declare function generateMigrations(options?: GenerateMigrationsOptions):
131
145
  */
132
146
  export declare function referencesUndefinedType(statement: string, dangling: string[]): boolean;
133
147
  export declare function findDanglingTypeReferences(statements: string[]): string[];
148
+ /**
149
+ * Define the enum types an ALTER needs but nothing in the batch creates.
150
+ *
151
+ * Postgres enum columns are backed by a named type, and bun-query-builder names
152
+ * it `<table>_<column>_type` when it creates the table. A column that becomes an
153
+ * enum *later* gets an `ALTER … TYPE "<table>_<column>_type"` naming a type that
154
+ * was never created, because the `CREATE TYPE` only ever accompanied a
155
+ * `CREATE TABLE`.
156
+ *
157
+ * The values are right there in the plan - the model declared them - so the type
158
+ * can be created instead of the statement being thrown away. Throwing it away
159
+ * left the column as it was, so a model change quietly did not happen and the
160
+ * next diff proposed the same thing again forever.
161
+ */
162
+ export declare function createMissingEnumTypes(dangling: string[], plan: MigrationPlanLike | undefined): { statements: string[], defined: string[] };
134
163
  /**
135
164
  * SQLite supports a nullable foreign key on `ADD COLUMN`, but not a later
136
165
  * `ADD CONSTRAINT`. bun-query-builder emits the new relation column without
@@ -192,6 +221,7 @@ declare interface MigrationPlanColumn {
192
221
  table?: string
193
222
  column?: string
194
223
  }
224
+ enumValues?: string[]
195
225
  }
196
226
  declare interface MigrationPlanTable {
197
227
  table?: string
@@ -96,9 +96,70 @@ export function prepareMigrationModelsDir() {
96
96
  };
97
97
  }
98
98
  export function sqlStatementsOf(content) {
99
- return content.split(`
100
- `).map((line) => line.replace(/--.*$/, "")).join(`
101
- `).split(";").map((s) => s.trim()).filter((s) => s.length > 0);
99
+ const statements = [];
100
+ let current = "", quote = null, dollarTag = null;
101
+ for (let i = 0;i < content.length; i++) {
102
+ const char = content[i];
103
+ if (dollarTag) {
104
+ current += char;
105
+ if (char === "$" && content.startsWith(dollarTag, i)) {
106
+ current += content.slice(i + 1, i + dollarTag.length);
107
+ i += dollarTag.length - 1;
108
+ dollarTag = null;
109
+ }
110
+ continue;
111
+ }
112
+ if (quote) {
113
+ current += char;
114
+ if (quote === "single" && char === "'" || quote === "double" && char === '"')
115
+ quote = null;
116
+ continue;
117
+ }
118
+ if (char === "-" && content[i + 1] === "-") {
119
+ const newline = content.indexOf(`
120
+ `, i);
121
+ if (newline === -1)
122
+ break;
123
+ i = newline - 1;
124
+ continue;
125
+ }
126
+ const dollar = char === "$" ? /^\$[A-Za-z_]*\$/.exec(content.slice(i)) : null;
127
+ if (dollar) {
128
+ dollarTag = dollar[0];
129
+ current += dollarTag;
130
+ i += dollarTag.length - 1;
131
+ continue;
132
+ }
133
+ if (char === "'") {
134
+ quote = "single";
135
+ current += char;
136
+ continue;
137
+ }
138
+ if (char === '"') {
139
+ quote = "double";
140
+ current += char;
141
+ continue;
142
+ }
143
+ if (char === ";") {
144
+ const trimmed = current.trim();
145
+ if (trimmed.length > 0)
146
+ statements.push(trimmed);
147
+ current = "";
148
+ continue;
149
+ }
150
+ current += char;
151
+ }
152
+ const trailing = current.trim();
153
+ if (trailing.length > 0)
154
+ statements.push(trailing);
155
+ return statements;
156
+ }
157
+ export function guardPostgresEnumTypes(sql) {
158
+ return sql.replace(/CREATE\s+TYPE\s+("?[\w.]+"?)\s+AS\s+ENUM\s*\(([^)]*)\)/gi, (match, name, members, offset, whole) => {
159
+ if (/\bBEGIN\s*$/i.test(whole.slice(Math.max(0, offset - 40), offset)))
160
+ return match;
161
+ return `DO $stacks$ BEGIN CREATE TYPE ${name} AS ENUM (${members}); EXCEPTION WHEN duplicate_object THEN null; END $stacks$`;
162
+ });
102
163
  }
103
164
  export function preprocessSqliteMigrations() {
104
165
  const migrationsDir = join(process.cwd(), "database", "migrations");
@@ -484,9 +545,9 @@ function makeMigrationsIdempotent() {
484
545
  } catch {
485
546
  continue;
486
547
  }
487
- if (!/\bADD\s+(?:COLUMN|CONSTRAINT)\b/i.test(sql))
548
+ if (!(/\bADD\s+(?:COLUMN|CONSTRAINT)\b/i.test(sql) || /\bCREATE\s+TYPE\b/i.test(sql)))
488
549
  continue;
489
- const next = idempotentSql(sql);
550
+ const next = guardPostgresEnumTypes(idempotentSql(sql));
490
551
  if (next !== sql)
491
552
  try {
492
553
  writeFileSync(p, next);
@@ -751,9 +812,17 @@ export async function generateMigrations(options = {}) {
751
812
  if (result.hasChanges && sqlStatements.length > 0) {
752
813
  const dangling = findDanglingTypeReferences(sqlStatements);
753
814
  if (dangling.length > 0) {
754
- const before = sqlStatements.length;
755
- sqlStatements = sqlStatements.filter((statement) => !referencesUndefinedType(statement, dangling));
756
- log.warn(`[migration] Skipped ${before - sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates (${dangling.slice(0, 3).join(", ")}${dangling.length > 3 ? ", \u2026" : ""}). This is a bug in the migration generator, not in your models.`);
815
+ const created = createMissingEnumTypes(dangling, result.plan);
816
+ if (created.statements.length > 0) {
817
+ sqlStatements = [...created.statements, ...sqlStatements];
818
+ log.debug(`[migration] Created ${created.statements.length} enum type(s) an ALTER needed: ${created.defined.join(", ")}`);
819
+ }
820
+ const unresolved = dangling.filter((name) => !created.defined.includes(name));
821
+ if (unresolved.length > 0) {
822
+ const before = sqlStatements.length;
823
+ sqlStatements = sqlStatements.filter((statement) => !referencesUndefinedType(statement, unresolved));
824
+ log.warn(`[migration] Skipped ${before - sqlStatements.length} generated statement(s) referencing enum type(s) nothing creates and no model defines values for (${unresolved.slice(0, 3).join(", ")}${unresolved.length > 3 ? ", \u2026" : ""}).`);
825
+ }
757
826
  }
758
827
  }
759
828
  if (result.hasChanges) {
@@ -787,6 +856,30 @@ export function findDanglingTypeReferences(statements) {
787
856
  }
788
857
  return [...referenced].filter((name) => !defined.has(name)).sort();
789
858
  }
859
+ export function createMissingEnumTypes(dangling, plan) {
860
+ if (dangling.length === 0)
861
+ return { statements: [], defined: [] };
862
+ const values = new Map;
863
+ for (const table of plan?.tables ?? []) {
864
+ if (!table.table)
865
+ continue;
866
+ for (const column of table.columns ?? []) {
867
+ if (!column.name || !column.enumValues?.length)
868
+ continue;
869
+ values.set(`${table.table}_${column.name}_type`, column.enumValues);
870
+ }
871
+ }
872
+ const statements = [], defined = [];
873
+ for (const name of dangling) {
874
+ const members = values.get(name);
875
+ if (!members?.length)
876
+ continue;
877
+ const literals = members.map((member) => `'${String(member).replaceAll("'", "''")}'`).join(", ");
878
+ statements.push(`${guardPostgresEnumTypes(`CREATE TYPE "${name}" AS ENUM (${literals})`)};`);
879
+ defined.push(name);
880
+ }
881
+ return { statements, defined };
882
+ }
790
883
  export function inlineSqliteAddedColumnReferences(statements, plan) {
791
884
  const references = new Map;
792
885
  for (const table of plan?.tables ?? []) {
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.240",
5
+ "version": "0.70.243",
6
6
  "description": "The Stacks database integration.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -54,19 +54,19 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@stacksjs/ts-validation": "^0.5.0",
57
- "bun-query-builder": "^0.2.2",
57
+ "bun-query-builder": "^0.2.3",
58
58
  "dynamodb-tooling": "^0.3.2"
59
59
  },
60
60
  "devDependencies": {
61
- "@stacksjs/cli": "0.70.240",
62
- "@stacksjs/config": "0.70.240",
63
- "@stacksjs/logging": "0.70.240",
64
- "@stacksjs/router": "0.70.240",
61
+ "@stacksjs/cli": "0.70.243",
62
+ "@stacksjs/config": "0.70.243",
63
+ "@stacksjs/logging": "0.70.243",
64
+ "@stacksjs/router": "0.70.243",
65
65
  "better-dx": "^0.2.17",
66
- "@stacksjs/path": "0.70.240",
67
- "@stacksjs/query-builder": "0.70.240",
68
- "@stacksjs/storage": "0.70.240",
69
- "@stacksjs/strings": "0.70.240",
70
- "@stacksjs/utils": "0.70.240"
66
+ "@stacksjs/path": "0.70.243",
67
+ "@stacksjs/query-builder": "0.70.243",
68
+ "@stacksjs/storage": "0.70.243",
69
+ "@stacksjs/strings": "0.70.243",
70
+ "@stacksjs/utils": "0.70.243"
71
71
  }
72
72
  }