@motherbase/cli 0.2.5 → 0.2.6
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/LICENSE +21 -0
- package/README.md +247 -247
- package/bin/motherbase.js +10 -10
- package/package.json +35 -26
- package/src/api.js +233 -241
- package/src/commands/_util.js +106 -93
- package/src/commands/backups.js +16 -16
- package/src/commands/branches.js +113 -113
- package/src/commands/completion.js +90 -90
- package/src/commands/config.js +47 -47
- package/src/commands/cron.js +65 -65
- package/src/commands/db-catalog.js +326 -326
- package/src/commands/db.js +103 -103
- package/src/commands/domains.js +124 -124
- package/src/commands/functions.js +304 -297
- package/src/commands/gen.js +172 -172
- package/src/commands/import.js +55 -55
- package/src/commands/index.js +85 -85
- package/src/commands/inspect.js +178 -178
- package/src/commands/keys.js +72 -72
- package/src/commands/link.js +35 -35
- package/src/commands/local.js +294 -273
- package/src/commands/login.js +39 -39
- package/src/commands/logout.js +16 -16
- package/src/commands/mcp.js +61 -61
- package/src/commands/migrate.js +207 -179
- package/src/commands/migration.js +108 -108
- package/src/commands/network.js +83 -83
- package/src/commands/orgs.js +40 -40
- package/src/commands/postgres-config.js +113 -113
- package/src/commands/projects.js +130 -130
- package/src/commands/queues.js +133 -133
- package/src/commands/realtime.js +35 -35
- package/src/commands/rls.js +176 -176
- package/src/commands/secrets.js +66 -66
- package/src/commands/signing.js +130 -130
- package/src/commands/snippets.js +44 -44
- package/src/commands/status.js +42 -42
- package/src/commands/storage.js +261 -261
- package/src/commands/test.js +9 -9
- package/src/commands/unlink.js +15 -15
- package/src/commands/users.js +142 -142
- package/src/commands/vault.js +82 -82
- package/src/commands/webhooks.js +62 -62
- package/src/commands/whoami.js +20 -20
- package/src/commands/wrappers.js +150 -150
- package/src/config.js +98 -98
- package/src/context.js +106 -102
- package/src/docs.js +68 -0
- package/src/edge-templates.js +194 -194
- package/src/format.js +92 -92
- package/src/inspect/csv.js +22 -22
- package/src/inspect/queries.js +562 -562
- package/src/main.js +224 -194
- package/src/mcp/platform-tools.js +233 -233
- package/src/mcp/protocol.js +152 -149
- package/src/mcp/storage-tools.js +93 -93
- package/src/mcp/tools.js +478 -478
- package/src/migration/commands.js +375 -375
- package/src/migration/files.js +245 -245
- package/src/prompt.js +91 -91
- package/src/storage/remote.js +62 -62
- package/src/ui.js +111 -111
package/src/migration/files.js
CHANGED
|
@@ -1,245 +1,245 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// The local half of the migration workflow: the `motherbase/` directory, laid
|
|
4
|
-
// out the way a Supabase project lays out `supabase/`:
|
|
5
|
-
//
|
|
6
|
-
// motherbase/
|
|
7
|
-
// migrations/<version>_<name>.sql one migration per file, applied in version order
|
|
8
|
-
// seed.sql data for `db reset` and `db push --include-seed`
|
|
9
|
-
// tests/*.sql pgTAP tests for `motherbase test db`
|
|
10
|
-
// config.json optional: { "db": { "seed": { "sql_paths": [...] } } }
|
|
11
|
-
//
|
|
12
|
-
// A file holds its up SQL, then optionally a `-- @down` line and the SQL that
|
|
13
|
-
// undoes it: the convention the Git sync reads (apps/server migration-files.ts).
|
|
14
|
-
//
|
|
15
|
-
// Everything here except the small fs readers is pure, so the parts that decide
|
|
16
|
-
// what gets applied (naming, ordering, reconciliation, squashing) are tested
|
|
17
|
-
// without a server.
|
|
18
|
-
|
|
19
|
-
const fs = require('fs');
|
|
20
|
-
const path = require('path');
|
|
21
|
-
const { CliError } = require('../context');
|
|
22
|
-
|
|
23
|
-
const PROJECT_DIR = 'motherbase';
|
|
24
|
-
const MIGRATIONS_DIR = path.join(PROJECT_DIR, 'migrations');
|
|
25
|
-
const TESTS_DIR = path.join(PROJECT_DIR, 'tests');
|
|
26
|
-
const CONFIG_FILE = path.join(PROJECT_DIR, 'config.json');
|
|
27
|
-
const DEFAULT_SEED_PATHS = ['./seed.sql'];
|
|
28
|
-
|
|
29
|
-
const FILE_NAME = /^(\d+)_(.+)\.sql$/;
|
|
30
|
-
/** `-- @down`, `--- @down`, `-- migrate:down`. Own line, any case, any indent. */
|
|
31
|
-
const DOWN_MARKER = /^[ \t]*--+[ \t]*(?:migrate:)?@?down[ \t]*$/im;
|
|
32
|
-
|
|
33
|
-
// ── Names ────────────────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
/** `20260101120000_add_orders.sql` → { version, name }; null outside the convention. */
|
|
36
|
-
function parseFileName(fileName) {
|
|
37
|
-
const match = FILE_NAME.exec(fileName);
|
|
38
|
-
return match ? { version: match[1], name: match[2] } : null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** The version `migration new` stamps: UTC, to the second. */
|
|
42
|
-
function versionAt(date = new Date()) {
|
|
43
|
-
return date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** A file name for a history entry. Studio names are free text: keep them file-safe. */
|
|
47
|
-
function fileNameFor(version, name) {
|
|
48
|
-
const safe = String(name).trim().replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'migration';
|
|
49
|
-
return `${version}_${safe}.sql`;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function byVersion(a, b) {
|
|
53
|
-
return a.version < b.version ? -1 : a.version > b.version ? 1 : 0;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ── File contents ────────────────────────────────────────────────────────────
|
|
57
|
-
|
|
58
|
-
/** Split a file into up and down SQL at the `-- @down` marker. */
|
|
59
|
-
function splitDown(contents) {
|
|
60
|
-
const marker = DOWN_MARKER.exec(contents);
|
|
61
|
-
if (!marker) return { sql: contents.trim(), downSql: null };
|
|
62
|
-
const down = contents.slice(marker.index + marker[0].length).trim();
|
|
63
|
-
// Comments only (what `migration new` scaffolds) is no down SQL at all.
|
|
64
|
-
const hasSql = down.replace(/\/\*[\s\S]*?\*\//g, '').replace(/--[^\n]*/g, '').trim();
|
|
65
|
-
return { sql: contents.slice(0, marker.index).trim(), downSql: hasSql ? down : null };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function renderFile(sql, downSql) {
|
|
69
|
-
const up = `${String(sql).trim()}\n`;
|
|
70
|
-
return downSql && downSql.trim() ? `${up}\n-- @down\n${downSql.trim()}\n` : up;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// ── Reading the directory ────────────────────────────────────────────────────
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Every migration file, in version order, with its SQL. Files that do not
|
|
77
|
-
* match `<version>_<name>.sql` are reported, not applied, exactly as Supabase
|
|
78
|
-
* skips them.
|
|
79
|
-
*/
|
|
80
|
-
function readLocalMigrations(cwd) {
|
|
81
|
-
const dir = path.join(cwd, MIGRATIONS_DIR);
|
|
82
|
-
if (!fs.existsSync(dir)) return { migrations: [], skipped: [] };
|
|
83
|
-
const migrations = [];
|
|
84
|
-
const skipped = [];
|
|
85
|
-
for (const fileName of fs.readdirSync(dir).sort()) {
|
|
86
|
-
if (!fileName.toLowerCase().endsWith('.sql')) continue;
|
|
87
|
-
const parsed = parseFileName(fileName);
|
|
88
|
-
if (!parsed) { skipped.push(fileName); continue; }
|
|
89
|
-
const file = path.join(dir, fileName);
|
|
90
|
-
migrations.push({ ...parsed, file, ...splitDown(fs.readFileSync(file, 'utf8')) });
|
|
91
|
-
}
|
|
92
|
-
return { migrations: migrations.sort(byVersion), skipped };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function readConfig(cwd) {
|
|
96
|
-
const file = path.join(cwd, CONFIG_FILE);
|
|
97
|
-
if (!fs.existsSync(file)) return {};
|
|
98
|
-
try {
|
|
99
|
-
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
100
|
-
} catch (err) {
|
|
101
|
-
throw new CliError(`${CONFIG_FILE} is not valid JSON: ${err.message}`);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** A glob with `*` / `?` in the last segment, like Supabase's `sql_paths`. */
|
|
106
|
-
function expandPath(baseDir, pattern) {
|
|
107
|
-
const full = path.resolve(baseDir, pattern);
|
|
108
|
-
const base = path.basename(full);
|
|
109
|
-
if (!/[*?]/.test(base)) return fs.existsSync(full) ? [full] : [];
|
|
110
|
-
const dir = path.dirname(full);
|
|
111
|
-
if (!fs.existsSync(dir)) return [];
|
|
112
|
-
const re = new RegExp(`^${base.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
|
|
113
|
-
return fs.readdirSync(dir).filter(name => re.test(name)).sort().map(name => path.join(dir, name));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* The seed files, in order: `db.seed.sql_paths` from motherbase/config.json
|
|
118
|
-
* (relative to motherbase/), or `seed.sql`. `db.seed.enabled: false` turns
|
|
119
|
-
* seeding off. A path that matches nothing is skipped; the default seed.sql is
|
|
120
|
-
* optional, as in Supabase.
|
|
121
|
-
*/
|
|
122
|
-
function readSeeds(cwd) {
|
|
123
|
-
const seed = (readConfig(cwd).db || {}).seed || {};
|
|
124
|
-
if (seed.enabled === false) return [];
|
|
125
|
-
const patterns = Array.isArray(seed.sql_paths) && seed.sql_paths.length ? seed.sql_paths : DEFAULT_SEED_PATHS;
|
|
126
|
-
const baseDir = path.join(cwd, PROJECT_DIR);
|
|
127
|
-
const seen = new Set();
|
|
128
|
-
const files = [];
|
|
129
|
-
for (const pattern of patterns) {
|
|
130
|
-
for (const file of expandPath(baseDir, pattern)) {
|
|
131
|
-
if (seen.has(file)) continue;
|
|
132
|
-
seen.add(file);
|
|
133
|
-
files.push({ path: path.relative(cwd, file), sql: fs.readFileSync(file, 'utf8') });
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return files;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** pgTAP files under motherbase/tests, recursively, in path order. */
|
|
140
|
-
function readTests(cwd, only = []) {
|
|
141
|
-
if (only.length) return only.map(file => ({ path: file, sql: fs.readFileSync(path.resolve(cwd, file), 'utf8') }));
|
|
142
|
-
const root = path.join(cwd, TESTS_DIR);
|
|
143
|
-
const found = [];
|
|
144
|
-
const walk = dir => {
|
|
145
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
146
|
-
const full = path.join(dir, entry.name);
|
|
147
|
-
if (entry.isDirectory()) walk(full);
|
|
148
|
-
else if (entry.name.toLowerCase().endsWith('.sql')) found.push(full);
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
if (fs.existsSync(root)) walk(root);
|
|
152
|
-
return found.sort().map(file => ({ path: path.relative(cwd, file), sql: fs.readFileSync(file, 'utf8') }));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ── Local vs remote ──────────────────────────────────────────────────────────
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* One row per version, local and remote side by side: what `migration list`
|
|
159
|
-
* prints. `remote` is the history entry or null; only an applied entry counts
|
|
160
|
-
* as "on the remote", a rolled-back one is history, not schema.
|
|
161
|
-
*/
|
|
162
|
-
function reconcile(local, remote) {
|
|
163
|
-
const rows = new Map();
|
|
164
|
-
for (const file of local) rows.set(file.version, { version: file.version, name: file.name, local: file, remote: null });
|
|
165
|
-
for (const entry of remote) {
|
|
166
|
-
const row = rows.get(entry.version) || { version: entry.version, name: entry.name, local: null, remote: null };
|
|
167
|
-
row.remote = entry;
|
|
168
|
-
rows.set(entry.version, row);
|
|
169
|
-
}
|
|
170
|
-
return [...rows.values()].sort(byVersion).map(row => ({
|
|
171
|
-
...row,
|
|
172
|
-
applied: Boolean(row.remote && row.remote.status === 'applied'),
|
|
173
|
-
}));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* What `db push` would do, and why it must refuse, Supabase's rules:
|
|
178
|
-
* * a migration applied on the remote with no local file means local is
|
|
179
|
-
* behind: fetch or repair first, never push over it;
|
|
180
|
-
* * a pending file older than the last applied remote version was written
|
|
181
|
-
* against a schema the remote has moved past: only with --include-all.
|
|
182
|
-
*/
|
|
183
|
-
function planPush(rows, { includeAll = false } = {}) {
|
|
184
|
-
const missingLocal = rows.filter(row => row.applied && !row.local).map(row => row.version);
|
|
185
|
-
const pending = rows.filter(row => row.local && !row.applied).map(row => row.local);
|
|
186
|
-
const lastApplied = rows.filter(row => row.applied).map(row => row.version).pop() || null;
|
|
187
|
-
const outOfOrder = lastApplied ? pending.filter(file => file.version < lastApplied).map(file => file.version) : [];
|
|
188
|
-
|
|
189
|
-
let error = null;
|
|
190
|
-
if (missingLocal.length) {
|
|
191
|
-
error =
|
|
192
|
-
`Remote migration versions not found in ${MIGRATIONS_DIR}: ${missingLocal.join(', ')}.\n` +
|
|
193
|
-
'Pull them with `motherbase migration fetch`, or mark them reverted with ' +
|
|
194
|
-
`\`motherbase migration repair --status reverted ${missingLocal.join(' ')}\`.`;
|
|
195
|
-
} else if (outOfOrder.length && !includeAll) {
|
|
196
|
-
error =
|
|
197
|
-
`Found local migrations to be inserted before the last migration on the remote (${lastApplied}): ${outOfOrder.join(', ')}.\n` +
|
|
198
|
-
'Rerun with --include-all to apply them anyway.';
|
|
199
|
-
}
|
|
200
|
-
return { pending, missingLocal, outOfOrder, error };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Fold migrations into one, Supabase's `migration squash`: kept under the
|
|
205
|
-
* newest version (so the remote history still ends where it did), with every
|
|
206
|
-
* up section in order and, only if every file has one, every down section in
|
|
207
|
-
* reverse. A partial down would roll back a fraction of the squash.
|
|
208
|
-
*/
|
|
209
|
-
function squash(migrations) {
|
|
210
|
-
if (migrations.length < 2) throw new CliError('Nothing to squash: need at least two migrations.');
|
|
211
|
-
const ordered = [...migrations].sort(byVersion);
|
|
212
|
-
const last = ordered[ordered.length - 1];
|
|
213
|
-
const label = file => `${file.version}_${file.name}`;
|
|
214
|
-
const up = ordered.map(file => `-- ${label(file)}\n${file.sql.trim()}`).join('\n\n');
|
|
215
|
-
const reversible = ordered.every(file => file.downSql);
|
|
216
|
-
const down = reversible
|
|
217
|
-
? [...ordered].reverse().map(file => `-- ${label(file)}\n${file.downSql.trim()}`).join('\n\n')
|
|
218
|
-
: null;
|
|
219
|
-
const header = `-- Squashed from ${ordered.length} migrations: ${ordered.map(label).join(', ')}\n\n`;
|
|
220
|
-
return {
|
|
221
|
-
version: last.version,
|
|
222
|
-
name: last.name,
|
|
223
|
-
sql: `${header}${up}`,
|
|
224
|
-
downSql: down,
|
|
225
|
-
reversible,
|
|
226
|
-
replaced: ordered.map(label),
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
module.exports = {
|
|
231
|
-
PROJECT_DIR,
|
|
232
|
-
MIGRATIONS_DIR,
|
|
233
|
-
TESTS_DIR,
|
|
234
|
-
parseFileName,
|
|
235
|
-
versionAt,
|
|
236
|
-
fileNameFor,
|
|
237
|
-
splitDown,
|
|
238
|
-
renderFile,
|
|
239
|
-
readLocalMigrations,
|
|
240
|
-
readSeeds,
|
|
241
|
-
readTests,
|
|
242
|
-
reconcile,
|
|
243
|
-
planPush,
|
|
244
|
-
squash,
|
|
245
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The local half of the migration workflow: the `motherbase/` directory, laid
|
|
4
|
+
// out the way a Supabase project lays out `supabase/`:
|
|
5
|
+
//
|
|
6
|
+
// motherbase/
|
|
7
|
+
// migrations/<version>_<name>.sql one migration per file, applied in version order
|
|
8
|
+
// seed.sql data for `db reset` and `db push --include-seed`
|
|
9
|
+
// tests/*.sql pgTAP tests for `motherbase test db`
|
|
10
|
+
// config.json optional: { "db": { "seed": { "sql_paths": [...] } } }
|
|
11
|
+
//
|
|
12
|
+
// A file holds its up SQL, then optionally a `-- @down` line and the SQL that
|
|
13
|
+
// undoes it: the convention the Git sync reads (apps/server migration-files.ts).
|
|
14
|
+
//
|
|
15
|
+
// Everything here except the small fs readers is pure, so the parts that decide
|
|
16
|
+
// what gets applied (naming, ordering, reconciliation, squashing) are tested
|
|
17
|
+
// without a server.
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { CliError } = require('../context');
|
|
22
|
+
|
|
23
|
+
const PROJECT_DIR = 'motherbase';
|
|
24
|
+
const MIGRATIONS_DIR = path.join(PROJECT_DIR, 'migrations');
|
|
25
|
+
const TESTS_DIR = path.join(PROJECT_DIR, 'tests');
|
|
26
|
+
const CONFIG_FILE = path.join(PROJECT_DIR, 'config.json');
|
|
27
|
+
const DEFAULT_SEED_PATHS = ['./seed.sql'];
|
|
28
|
+
|
|
29
|
+
const FILE_NAME = /^(\d+)_(.+)\.sql$/;
|
|
30
|
+
/** `-- @down`, `--- @down`, `-- migrate:down`. Own line, any case, any indent. */
|
|
31
|
+
const DOWN_MARKER = /^[ \t]*--+[ \t]*(?:migrate:)?@?down[ \t]*$/im;
|
|
32
|
+
|
|
33
|
+
// ── Names ────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** `20260101120000_add_orders.sql` → { version, name }; null outside the convention. */
|
|
36
|
+
function parseFileName(fileName) {
|
|
37
|
+
const match = FILE_NAME.exec(fileName);
|
|
38
|
+
return match ? { version: match[1], name: match[2] } : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The version `migration new` stamps: UTC, to the second. */
|
|
42
|
+
function versionAt(date = new Date()) {
|
|
43
|
+
return date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A file name for a history entry. Studio names are free text: keep them file-safe. */
|
|
47
|
+
function fileNameFor(version, name) {
|
|
48
|
+
const safe = String(name).trim().replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'migration';
|
|
49
|
+
return `${version}_${safe}.sql`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function byVersion(a, b) {
|
|
53
|
+
return a.version < b.version ? -1 : a.version > b.version ? 1 : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── File contents ────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/** Split a file into up and down SQL at the `-- @down` marker. */
|
|
59
|
+
function splitDown(contents) {
|
|
60
|
+
const marker = DOWN_MARKER.exec(contents);
|
|
61
|
+
if (!marker) return { sql: contents.trim(), downSql: null };
|
|
62
|
+
const down = contents.slice(marker.index + marker[0].length).trim();
|
|
63
|
+
// Comments only (what `migration new` scaffolds) is no down SQL at all.
|
|
64
|
+
const hasSql = down.replace(/\/\*[\s\S]*?\*\//g, '').replace(/--[^\n]*/g, '').trim();
|
|
65
|
+
return { sql: contents.slice(0, marker.index).trim(), downSql: hasSql ? down : null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderFile(sql, downSql) {
|
|
69
|
+
const up = `${String(sql).trim()}\n`;
|
|
70
|
+
return downSql && downSql.trim() ? `${up}\n-- @down\n${downSql.trim()}\n` : up;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Reading the directory ────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Every migration file, in version order, with its SQL. Files that do not
|
|
77
|
+
* match `<version>_<name>.sql` are reported, not applied, exactly as Supabase
|
|
78
|
+
* skips them.
|
|
79
|
+
*/
|
|
80
|
+
function readLocalMigrations(cwd) {
|
|
81
|
+
const dir = path.join(cwd, MIGRATIONS_DIR);
|
|
82
|
+
if (!fs.existsSync(dir)) return { migrations: [], skipped: [] };
|
|
83
|
+
const migrations = [];
|
|
84
|
+
const skipped = [];
|
|
85
|
+
for (const fileName of fs.readdirSync(dir).sort()) {
|
|
86
|
+
if (!fileName.toLowerCase().endsWith('.sql')) continue;
|
|
87
|
+
const parsed = parseFileName(fileName);
|
|
88
|
+
if (!parsed) { skipped.push(fileName); continue; }
|
|
89
|
+
const file = path.join(dir, fileName);
|
|
90
|
+
migrations.push({ ...parsed, file, ...splitDown(fs.readFileSync(file, 'utf8')) });
|
|
91
|
+
}
|
|
92
|
+
return { migrations: migrations.sort(byVersion), skipped };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readConfig(cwd) {
|
|
96
|
+
const file = path.join(cwd, CONFIG_FILE);
|
|
97
|
+
if (!fs.existsSync(file)) return {};
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new CliError(`${CONFIG_FILE} is not valid JSON: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A glob with `*` / `?` in the last segment, like Supabase's `sql_paths`. */
|
|
106
|
+
function expandPath(baseDir, pattern) {
|
|
107
|
+
const full = path.resolve(baseDir, pattern);
|
|
108
|
+
const base = path.basename(full);
|
|
109
|
+
if (!/[*?]/.test(base)) return fs.existsSync(full) ? [full] : [];
|
|
110
|
+
const dir = path.dirname(full);
|
|
111
|
+
if (!fs.existsSync(dir)) return [];
|
|
112
|
+
const re = new RegExp(`^${base.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
|
|
113
|
+
return fs.readdirSync(dir).filter(name => re.test(name)).sort().map(name => path.join(dir, name));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The seed files, in order: `db.seed.sql_paths` from motherbase/config.json
|
|
118
|
+
* (relative to motherbase/), or `seed.sql`. `db.seed.enabled: false` turns
|
|
119
|
+
* seeding off. A path that matches nothing is skipped; the default seed.sql is
|
|
120
|
+
* optional, as in Supabase.
|
|
121
|
+
*/
|
|
122
|
+
function readSeeds(cwd) {
|
|
123
|
+
const seed = (readConfig(cwd).db || {}).seed || {};
|
|
124
|
+
if (seed.enabled === false) return [];
|
|
125
|
+
const patterns = Array.isArray(seed.sql_paths) && seed.sql_paths.length ? seed.sql_paths : DEFAULT_SEED_PATHS;
|
|
126
|
+
const baseDir = path.join(cwd, PROJECT_DIR);
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
const files = [];
|
|
129
|
+
for (const pattern of patterns) {
|
|
130
|
+
for (const file of expandPath(baseDir, pattern)) {
|
|
131
|
+
if (seen.has(file)) continue;
|
|
132
|
+
seen.add(file);
|
|
133
|
+
files.push({ path: path.relative(cwd, file), sql: fs.readFileSync(file, 'utf8') });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return files;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** pgTAP files under motherbase/tests, recursively, in path order. */
|
|
140
|
+
function readTests(cwd, only = []) {
|
|
141
|
+
if (only.length) return only.map(file => ({ path: file, sql: fs.readFileSync(path.resolve(cwd, file), 'utf8') }));
|
|
142
|
+
const root = path.join(cwd, TESTS_DIR);
|
|
143
|
+
const found = [];
|
|
144
|
+
const walk = dir => {
|
|
145
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
146
|
+
const full = path.join(dir, entry.name);
|
|
147
|
+
if (entry.isDirectory()) walk(full);
|
|
148
|
+
else if (entry.name.toLowerCase().endsWith('.sql')) found.push(full);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
if (fs.existsSync(root)) walk(root);
|
|
152
|
+
return found.sort().map(file => ({ path: path.relative(cwd, file), sql: fs.readFileSync(file, 'utf8') }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Local vs remote ──────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One row per version, local and remote side by side: what `migration list`
|
|
159
|
+
* prints. `remote` is the history entry or null; only an applied entry counts
|
|
160
|
+
* as "on the remote", a rolled-back one is history, not schema.
|
|
161
|
+
*/
|
|
162
|
+
function reconcile(local, remote) {
|
|
163
|
+
const rows = new Map();
|
|
164
|
+
for (const file of local) rows.set(file.version, { version: file.version, name: file.name, local: file, remote: null });
|
|
165
|
+
for (const entry of remote) {
|
|
166
|
+
const row = rows.get(entry.version) || { version: entry.version, name: entry.name, local: null, remote: null };
|
|
167
|
+
row.remote = entry;
|
|
168
|
+
rows.set(entry.version, row);
|
|
169
|
+
}
|
|
170
|
+
return [...rows.values()].sort(byVersion).map(row => ({
|
|
171
|
+
...row,
|
|
172
|
+
applied: Boolean(row.remote && row.remote.status === 'applied'),
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* What `db push` would do, and why it must refuse, Supabase's rules:
|
|
178
|
+
* * a migration applied on the remote with no local file means local is
|
|
179
|
+
* behind: fetch or repair first, never push over it;
|
|
180
|
+
* * a pending file older than the last applied remote version was written
|
|
181
|
+
* against a schema the remote has moved past: only with --include-all.
|
|
182
|
+
*/
|
|
183
|
+
function planPush(rows, { includeAll = false } = {}) {
|
|
184
|
+
const missingLocal = rows.filter(row => row.applied && !row.local).map(row => row.version);
|
|
185
|
+
const pending = rows.filter(row => row.local && !row.applied).map(row => row.local);
|
|
186
|
+
const lastApplied = rows.filter(row => row.applied).map(row => row.version).pop() || null;
|
|
187
|
+
const outOfOrder = lastApplied ? pending.filter(file => file.version < lastApplied).map(file => file.version) : [];
|
|
188
|
+
|
|
189
|
+
let error = null;
|
|
190
|
+
if (missingLocal.length) {
|
|
191
|
+
error =
|
|
192
|
+
`Remote migration versions not found in ${MIGRATIONS_DIR}: ${missingLocal.join(', ')}.\n` +
|
|
193
|
+
'Pull them with `motherbase migration fetch`, or mark them reverted with ' +
|
|
194
|
+
`\`motherbase migration repair --status reverted ${missingLocal.join(' ')}\`.`;
|
|
195
|
+
} else if (outOfOrder.length && !includeAll) {
|
|
196
|
+
error =
|
|
197
|
+
`Found local migrations to be inserted before the last migration on the remote (${lastApplied}): ${outOfOrder.join(', ')}.\n` +
|
|
198
|
+
'Rerun with --include-all to apply them anyway.';
|
|
199
|
+
}
|
|
200
|
+
return { pending, missingLocal, outOfOrder, error };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Fold migrations into one, Supabase's `migration squash`: kept under the
|
|
205
|
+
* newest version (so the remote history still ends where it did), with every
|
|
206
|
+
* up section in order and, only if every file has one, every down section in
|
|
207
|
+
* reverse. A partial down would roll back a fraction of the squash.
|
|
208
|
+
*/
|
|
209
|
+
function squash(migrations) {
|
|
210
|
+
if (migrations.length < 2) throw new CliError('Nothing to squash: need at least two migrations.');
|
|
211
|
+
const ordered = [...migrations].sort(byVersion);
|
|
212
|
+
const last = ordered[ordered.length - 1];
|
|
213
|
+
const label = file => `${file.version}_${file.name}`;
|
|
214
|
+
const up = ordered.map(file => `-- ${label(file)}\n${file.sql.trim()}`).join('\n\n');
|
|
215
|
+
const reversible = ordered.every(file => file.downSql);
|
|
216
|
+
const down = reversible
|
|
217
|
+
? [...ordered].reverse().map(file => `-- ${label(file)}\n${file.downSql.trim()}`).join('\n\n')
|
|
218
|
+
: null;
|
|
219
|
+
const header = `-- Squashed from ${ordered.length} migrations: ${ordered.map(label).join(', ')}\n\n`;
|
|
220
|
+
return {
|
|
221
|
+
version: last.version,
|
|
222
|
+
name: last.name,
|
|
223
|
+
sql: `${header}${up}`,
|
|
224
|
+
downSql: down,
|
|
225
|
+
reversible,
|
|
226
|
+
replaced: ordered.map(label),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
PROJECT_DIR,
|
|
232
|
+
MIGRATIONS_DIR,
|
|
233
|
+
TESTS_DIR,
|
|
234
|
+
parseFileName,
|
|
235
|
+
versionAt,
|
|
236
|
+
fileNameFor,
|
|
237
|
+
splitDown,
|
|
238
|
+
renderFile,
|
|
239
|
+
readLocalMigrations,
|
|
240
|
+
readSeeds,
|
|
241
|
+
readTests,
|
|
242
|
+
reconcile,
|
|
243
|
+
planPush,
|
|
244
|
+
squash,
|
|
245
|
+
};
|