@coldge.com/gitbase 1.0.2 → 1.0.3

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,245 @@
1
+ /**
2
+ * sqlDiff.ts — Smart schema diffing utilities
3
+ *
4
+ * Generates the minimal set of ALTER statements needed to transition a schema
5
+ * object from one state to another, avoiding destructive DROP+CREATE whenever
6
+ * possible. This preserves existing data in tables and avoids any unnecessary
7
+ * downtime on enum types.
8
+ *
9
+ * The parsers here are intentionally simple: they understand our canonical DDL
10
+ * format as generated by extractor.ts, not arbitrary user-written SQL.
11
+ */
12
+ // ---------------------------------------------------------------------------
13
+ // Table parsing
14
+ // ---------------------------------------------------------------------------
15
+ /**
16
+ * Splits the body of a CREATE TABLE statement into lines, handling nested
17
+ * parentheses (e.g. FOREIGN KEY (...) REFERENCES foo(id)) correctly.
18
+ */
19
+ function splitTableBody(body) {
20
+ const parts = [];
21
+ let current = '';
22
+ let depth = 0;
23
+ for (const ch of body) {
24
+ if (ch === '(')
25
+ depth++;
26
+ else if (ch === ')')
27
+ depth--;
28
+ if (ch === ',' && depth === 0) {
29
+ const trimmed = current.trim();
30
+ if (trimmed)
31
+ parts.push(trimmed);
32
+ current = '';
33
+ }
34
+ else {
35
+ current += ch;
36
+ }
37
+ }
38
+ const trimmed = current.trim();
39
+ if (trimmed)
40
+ parts.push(trimmed);
41
+ return parts;
42
+ }
43
+ /**
44
+ * Parses the canonical CREATE TABLE SQL generated by extractor.ts into its
45
+ * constituent parts: columns, table constraints, indexes, and RLS status.
46
+ */
47
+ export function extractTableParts(sql) {
48
+ const columns = new Map();
49
+ const constraints = new Map();
50
+ const indexes = new Map();
51
+ // Extract the body between the outer ( ... );
52
+ const bodyMatch = sql.match(/CREATE TABLE [^(]+\((\n[\s\S]*?\n)\);/);
53
+ if (bodyMatch) {
54
+ const bodyLines = splitTableBody(bodyMatch[1]);
55
+ for (const line of bodyLines) {
56
+ if (!line)
57
+ continue;
58
+ if (line.startsWith('CONSTRAINT ')) {
59
+ // e.g. CONSTRAINT "pk_name" PRIMARY KEY (col)
60
+ const m = line.match(/^CONSTRAINT "([^"]+)"\s+([\s\S]+)$/);
61
+ if (m)
62
+ constraints.set(m[1], m[2].trim());
63
+ }
64
+ else if (line.startsWith('"')) {
65
+ // e.g. "colname" integer NOT NULL DEFAULT 42
66
+ // Our format is always: "name" type [NOT NULL] [DEFAULT val]
67
+ const m = line.match(/^"([^"]+)"\s+([^\s]+)(.*)/);
68
+ if (m) {
69
+ const [, name, type, rest] = m;
70
+ const notNull = /\bNOT NULL\b/i.test(rest);
71
+ // DEFAULT is always last in our format
72
+ const defaultMatch = rest.match(/\bDEFAULT\s+(.+)$/i);
73
+ columns.set(name, {
74
+ name,
75
+ type,
76
+ notNull,
77
+ default: defaultMatch ? defaultMatch[1].trim() : null,
78
+ });
79
+ }
80
+ }
81
+ }
82
+ }
83
+ // Extract standalone CREATE INDEX statements (after the closing ");")
84
+ const indexRegex = /CREATE (?:UNIQUE )?INDEX "([^"]+)" ON [^\n]+/gi;
85
+ let im;
86
+ while ((im = indexRegex.exec(sql)) !== null) {
87
+ indexes.set(im[1], im[0]);
88
+ }
89
+ const rlsEnabled = /ENABLE ROW LEVEL SECURITY/i.test(sql);
90
+ return { columns, constraints, indexes, rlsEnabled };
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // Table ALTER generation
94
+ // ---------------------------------------------------------------------------
95
+ /**
96
+ * Generates the minimal `ALTER TABLE` statements to transition a table's
97
+ * schema from `oldSql` to `newSql`, preserving all existing rows.
98
+ *
99
+ * Returns an empty array if the schemas are identical.
100
+ * Always succeeds — the caller should never need to fall back to DROP+CREATE
101
+ * for an existing table (unless the table is being renamed or schema-moved,
102
+ * which is out of scope).
103
+ */
104
+ export function generateTableAlter(tableName, oldSql, newSql) {
105
+ const alters = [];
106
+ const q = `public."${tableName}"`;
107
+ const oldParts = extractTableParts(oldSql);
108
+ const newParts = extractTableParts(newSql);
109
+ // 1. DROP removed constraints first — they may reference columns we'll drop
110
+ for (const [name] of oldParts.constraints) {
111
+ if (!newParts.constraints.has(name)) {
112
+ alters.push(`ALTER TABLE ${q} DROP CONSTRAINT IF EXISTS "${name}";`);
113
+ }
114
+ else if (oldParts.constraints.get(name) !== newParts.constraints.get(name)) {
115
+ // Constraint definition changed — drop + re-add below
116
+ alters.push(`ALTER TABLE ${q} DROP CONSTRAINT IF EXISTS "${name}";`);
117
+ }
118
+ }
119
+ // 2. DROP removed indexes
120
+ for (const [name] of oldParts.indexes) {
121
+ if (!newParts.indexes.has(name)) {
122
+ alters.push(`DROP INDEX IF EXISTS "${name}";`);
123
+ }
124
+ else if (oldParts.indexes.get(name) !== newParts.indexes.get(name)) {
125
+ alters.push(`DROP INDEX IF EXISTS "${name}";`);
126
+ }
127
+ }
128
+ // 3. DROP removed columns
129
+ for (const [name] of oldParts.columns) {
130
+ if (!newParts.columns.has(name)) {
131
+ alters.push(`ALTER TABLE ${q} DROP COLUMN IF EXISTS "${name}";`);
132
+ }
133
+ }
134
+ // 4. ADD new columns
135
+ for (const [name, col] of newParts.columns) {
136
+ if (!oldParts.columns.has(name)) {
137
+ let def = `"${name}" ${col.type}`;
138
+ if (col.notNull)
139
+ def += ' NOT NULL';
140
+ if (col.default != null)
141
+ def += ` DEFAULT ${col.default}`;
142
+ alters.push(`ALTER TABLE ${q} ADD COLUMN ${def};`);
143
+ }
144
+ }
145
+ // 5. MODIFY changed columns
146
+ for (const [name, newCol] of newParts.columns) {
147
+ const oldCol = oldParts.columns.get(name);
148
+ if (!oldCol)
149
+ continue; // New column — already handled above
150
+ if (oldCol.type !== newCol.type) {
151
+ // Note: may fail for incompatible casts (e.g. text→integer with non-numeric data).
152
+ // The transaction wrapper will catch this and roll back cleanly.
153
+ alters.push(`ALTER TABLE ${q} ALTER COLUMN "${name}" TYPE ${newCol.type};`);
154
+ }
155
+ if (oldCol.notNull !== newCol.notNull) {
156
+ alters.push(newCol.notNull
157
+ ? `ALTER TABLE ${q} ALTER COLUMN "${name}" SET NOT NULL;`
158
+ : `ALTER TABLE ${q} ALTER COLUMN "${name}" DROP NOT NULL;`);
159
+ }
160
+ // Compare defaults via string equality (both come from information_schema so format is stable)
161
+ const oldDefault = oldCol.default ?? null;
162
+ const newDefault = newCol.default ?? null;
163
+ if (oldDefault !== newDefault) {
164
+ alters.push(newDefault != null
165
+ ? `ALTER TABLE ${q} ALTER COLUMN "${name}" SET DEFAULT ${newDefault};`
166
+ : `ALTER TABLE ${q} ALTER COLUMN "${name}" DROP DEFAULT;`);
167
+ }
168
+ }
169
+ // 6. ADD new or changed constraints
170
+ for (const [name, def] of newParts.constraints) {
171
+ const oldDef = oldParts.constraints.get(name);
172
+ if (!oldDef || oldDef !== def) {
173
+ alters.push(`ALTER TABLE ${q} ADD CONSTRAINT "${name}" ${def};`);
174
+ }
175
+ }
176
+ // 7. ADD new or changed indexes
177
+ for (const [name, def] of newParts.indexes) {
178
+ const oldDef = oldParts.indexes.get(name);
179
+ if (!oldDef || oldDef !== def) {
180
+ alters.push(`${def};`);
181
+ }
182
+ }
183
+ // 8. RLS status
184
+ if (newParts.rlsEnabled && !oldParts.rlsEnabled) {
185
+ alters.push(`ALTER TABLE ${q} ENABLE ROW LEVEL SECURITY;`);
186
+ }
187
+ else if (!newParts.rlsEnabled && oldParts.rlsEnabled) {
188
+ alters.push(`ALTER TABLE ${q} DISABLE ROW LEVEL SECURITY;`);
189
+ }
190
+ return alters;
191
+ }
192
+ // ---------------------------------------------------------------------------
193
+ // Enum ALTER generation
194
+ // ---------------------------------------------------------------------------
195
+ /**
196
+ * Extracts the ordered list of values from a CREATE TYPE ... AS ENUM (...) statement.
197
+ */
198
+ function extractEnumValues(sql) {
199
+ const match = sql.match(/AS ENUM\s*\(([^)]+)\)/i);
200
+ if (!match)
201
+ return [];
202
+ return match[1]
203
+ .split(',')
204
+ .map(v => v.trim().replace(/^'|'$/g, ''));
205
+ }
206
+ /**
207
+ * Attempts to generate `ALTER TYPE ... ADD VALUE` statements for additive
208
+ * enum changes (i.e. only new values added, no values removed or reordered).
209
+ *
210
+ * Returns:
211
+ * - `string[]` — ALTER TYPE statements to apply (may be empty if no change)
212
+ * - `null` — cannot safely alter; caller must DROP+CREATE
213
+ */
214
+ export function generateEnumAlter(typeName, oldSql, newSql) {
215
+ const oldValues = extractEnumValues(oldSql);
216
+ const newValues = extractEnumValues(newSql);
217
+ // Check if any existing values were removed — requires DROP+CREATE
218
+ for (const v of oldValues) {
219
+ if (!newValues.includes(v))
220
+ return null;
221
+ }
222
+ // Check if shared values were reordered — requires DROP+CREATE
223
+ // (PostgreSQL enum ordering is significant)
224
+ const sharedInOldOrder = oldValues.filter(v => newValues.includes(v));
225
+ const sharedInNewOrder = newValues.filter(v => oldValues.includes(v));
226
+ if (JSON.stringify(sharedInOldOrder) !== JSON.stringify(sharedInNewOrder))
227
+ return null;
228
+ // Only new values added — safe to use ADD VALUE
229
+ const addedValues = newValues.filter(v => !oldValues.includes(v));
230
+ if (addedValues.length === 0)
231
+ return []; // No change
232
+ return addedValues.map(val => {
233
+ const idx = newValues.indexOf(val);
234
+ if (idx === newValues.length - 1) {
235
+ // Adding at end — AFTER the preceding value
236
+ const after = newValues[idx - 1];
237
+ return `ALTER TYPE public."${typeName}" ADD VALUE '${val}' AFTER '${after}';`;
238
+ }
239
+ else {
240
+ // Adding before an existing value
241
+ const before = newValues[idx + 1];
242
+ return `ALTER TYPE public."${typeName}" ADD VALUE '${val}' BEFORE '${before}';`;
243
+ }
244
+ });
245
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coldge.com/gitbase",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "The Time Machine for Supabase",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -32,6 +32,7 @@
32
32
  "diff": "^8.0.3",
33
33
  "dotenv": "^17.3.1",
34
34
  "pg": "^8.19.0",
35
+ "pgsql-parser": "^17.9.11",
35
36
  "toposort": "^2.0.2",
36
37
  "yargs": "^17.0.0"
37
38
  },