@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.
- package/README.md +65 -15
- package/dist/api/supabase.js +139 -32
- package/dist/commands/branch.js +86 -14
- package/dist/commands/diff.js +180 -94
- package/dist/commands/init.js +8 -3
- package/dist/commands/log.js +85 -11
- package/dist/commands/merge.js +226 -15
- package/dist/commands/pull.js +171 -0
- package/dist/commands/push.js +324 -71
- package/dist/commands/revert.js +247 -95
- package/dist/commands/snapshot.js +49 -0
- package/dist/commands/stash.js +211 -0
- package/dist/commands/status.js +46 -38
- package/dist/commands/verify.js +120 -0
- package/dist/index.js +90 -10
- package/dist/schema/extractor.js +183 -26
- package/dist/schema/queries.js +160 -8
- package/dist/utils/hashing.js +52 -3
- package/dist/utils/sqlDiff.js +245 -0
- package/package.json +2 -1
|
@@ -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.
|
|
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
|
},
|