supabase-stateful 0.1.0
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 +92 -0
- package/bin/cli.js +59 -0
- package/package.json +34 -0
- package/src/commands/export.js +34 -0
- package/src/commands/init.js +89 -0
- package/src/commands/setup.js +622 -0
- package/src/commands/start.js +178 -0
- package/src/commands/status.js +65 -0
- package/src/commands/stop.js +50 -0
- package/src/commands/sync.js +63 -0
- package/src/lib/cloud.js +169 -0
- package/src/lib/config.js +75 -0
- package/src/lib/docker.js +96 -0
- package/src/lib/state.js +204 -0
- package/src/utils/log.js +21 -0
- package/src/utils/prompt.js +81 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start command - start Supabase and restore saved state
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. If already running: check for pending migrations, apply if needed
|
|
6
|
+
* 2. If not running: start Supabase (with fallbacks for common issues)
|
|
7
|
+
* 3. Restore saved state if it exists (schema + data from last session)
|
|
8
|
+
* 4. Run pending migrations ON TOP of existing data
|
|
9
|
+
*
|
|
10
|
+
* This order is critical - migrations run on your data, not on an empty database.
|
|
11
|
+
* E.g., if a teammate added a "rename column" migration, it transforms YOUR data.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execSync, spawnSync } from 'child_process';
|
|
15
|
+
import { restoreState, stateExists } from '../lib/state.js';
|
|
16
|
+
import { isRunning } from '../lib/docker.js';
|
|
17
|
+
import { log } from '../utils/log.js';
|
|
18
|
+
|
|
19
|
+
export async function start() {
|
|
20
|
+
log.info('Starting Supabase with stateful development...');
|
|
21
|
+
|
|
22
|
+
// Check if already running
|
|
23
|
+
if (isRunning()) {
|
|
24
|
+
log.success('Supabase already running');
|
|
25
|
+
await handleRunningInstance();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Start Supabase
|
|
30
|
+
if (!await startSupabase()) {
|
|
31
|
+
log.error('Failed to start Supabase');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Restore saved state FIRST (schema + data from last session)
|
|
36
|
+
await restoreSavedState();
|
|
37
|
+
|
|
38
|
+
// Apply pending migrations ON TOP of existing data
|
|
39
|
+
await applyMigrations();
|
|
40
|
+
|
|
41
|
+
printReady();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Handle when Supabase is already running
|
|
46
|
+
* Apply any pending migrations on top of existing data
|
|
47
|
+
*/
|
|
48
|
+
async function handleRunningInstance() {
|
|
49
|
+
// Apply pending migrations on top of existing data
|
|
50
|
+
await applyMigrations();
|
|
51
|
+
printReady();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Apply pending migrations ON TOP of existing data
|
|
56
|
+
* Uses `supabase migration up` instead of `db reset` to preserve data
|
|
57
|
+
*/
|
|
58
|
+
async function applyMigrations() {
|
|
59
|
+
log.info('Checking for pending migrations...');
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const output = execSync('supabase migration list --output json', {
|
|
63
|
+
encoding: 'utf8',
|
|
64
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const pendingCount = (output.match(/"Applied": false/g) || []).length;
|
|
68
|
+
|
|
69
|
+
if (pendingCount > 0) {
|
|
70
|
+
log.info(`Found ${pendingCount} pending migration(s)`);
|
|
71
|
+
log.info('Applying migrations on top of existing data...');
|
|
72
|
+
|
|
73
|
+
// Use `migration up` instead of `db reset` - this applies migrations WITHOUT wiping data
|
|
74
|
+
const result = spawnSync('supabase', ['migration', 'up'], {
|
|
75
|
+
stdio: 'inherit',
|
|
76
|
+
shell: true,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (result.status !== 0) {
|
|
80
|
+
log.error('Migration failed');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
log.success('Migrations applied');
|
|
85
|
+
} else {
|
|
86
|
+
log.success('No pending migrations');
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Migration check failed, try applying anyway
|
|
90
|
+
log.dim('Could not check migration status, attempting to apply...');
|
|
91
|
+
|
|
92
|
+
const result = spawnSync('supabase', ['migration', 'up'], {
|
|
93
|
+
stdio: 'inherit',
|
|
94
|
+
shell: true,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (result.status === 0) {
|
|
98
|
+
log.success('Migrations applied');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Start Supabase with fallbacks for common issues
|
|
105
|
+
*/
|
|
106
|
+
async function startSupabase() {
|
|
107
|
+
log.info('Starting Supabase...');
|
|
108
|
+
|
|
109
|
+
// Try normal start first
|
|
110
|
+
let result = spawnSync('supabase', ['start'], {
|
|
111
|
+
stdio: 'inherit',
|
|
112
|
+
shell: true,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (result.status === 0) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
log.warn('Standard start failed, trying alternatives...');
|
|
120
|
+
|
|
121
|
+
// Try without analytics (logflare often causes health check issues)
|
|
122
|
+
result = spawnSync('supabase', ['start', '--exclude', 'logflare'], {
|
|
123
|
+
stdio: 'inherit',
|
|
124
|
+
shell: true,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (result.status === 0) {
|
|
128
|
+
log.success('Started without analytics');
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Try ignoring health checks
|
|
133
|
+
result = spawnSync('supabase', ['start', '--ignore-health-check'], {
|
|
134
|
+
stdio: 'inherit',
|
|
135
|
+
shell: true,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (result.status === 0) {
|
|
139
|
+
log.success('Started ignoring health checks');
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Restore saved state if it exists
|
|
148
|
+
*/
|
|
149
|
+
async function restoreSavedState() {
|
|
150
|
+
if (await stateExists()) {
|
|
151
|
+
log.info('Found saved state - restoring...');
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await restoreState();
|
|
155
|
+
console.log('');
|
|
156
|
+
log.success('Previous session restored!');
|
|
157
|
+
console.log('');
|
|
158
|
+
console.log('Your test users and data have been restored');
|
|
159
|
+
console.log('Database schema updated and data preserved');
|
|
160
|
+
} catch {
|
|
161
|
+
log.warn('State restoration had some errors (likely duplicates - this is normal)');
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
log.info('No saved state found');
|
|
165
|
+
console.log('');
|
|
166
|
+
console.log('Create test users, then run: supabase-stateful stop');
|
|
167
|
+
console.log('Your state will be saved for next session');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Print ready message
|
|
173
|
+
*/
|
|
174
|
+
function printReady() {
|
|
175
|
+
console.log('');
|
|
176
|
+
console.log('Access Supabase Studio: http://localhost:54323');
|
|
177
|
+
log.success('Ready for development!');
|
|
178
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status command - show current state
|
|
3
|
+
*
|
|
4
|
+
* Displays:
|
|
5
|
+
* - Whether Supabase is running
|
|
6
|
+
* - Saved state info (exists, size, last modified)
|
|
7
|
+
* - Configuration details
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isRunning } from '../lib/docker.js';
|
|
11
|
+
import { getStateInfo } from '../lib/state.js';
|
|
12
|
+
import { getConfig, configExists } from '../lib/config.js';
|
|
13
|
+
import { log } from '../utils/log.js';
|
|
14
|
+
|
|
15
|
+
export async function status() {
|
|
16
|
+
console.log('');
|
|
17
|
+
console.log('Supabase Stateful Status');
|
|
18
|
+
console.log('========================');
|
|
19
|
+
console.log('');
|
|
20
|
+
|
|
21
|
+
// Check if initialized
|
|
22
|
+
if (!await configExists()) {
|
|
23
|
+
log.warn('Not initialized');
|
|
24
|
+
console.log('');
|
|
25
|
+
console.log('Run: supabase-stateful init');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const config = await getConfig();
|
|
30
|
+
|
|
31
|
+
// Supabase status
|
|
32
|
+
if (isRunning()) {
|
|
33
|
+
log.success('Supabase: Running');
|
|
34
|
+
} else {
|
|
35
|
+
log.info('Supabase: Stopped');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// State file status
|
|
39
|
+
const stateInfo = await getStateInfo();
|
|
40
|
+
if (stateInfo.exists) {
|
|
41
|
+
log.success(`State file: ${stateInfo.path}`);
|
|
42
|
+
console.log(` Size: ${stateInfo.size}`);
|
|
43
|
+
console.log(` Modified: ${stateInfo.modified.toLocaleString()}`);
|
|
44
|
+
} else {
|
|
45
|
+
log.info(`State file: Not saved yet`);
|
|
46
|
+
console.log(` Path: ${stateInfo.path}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Config info
|
|
50
|
+
console.log('');
|
|
51
|
+
console.log('Configuration:');
|
|
52
|
+
console.log(` Container: ${config.containerName}`);
|
|
53
|
+
console.log(` State file: ${config.stateFile}`);
|
|
54
|
+
|
|
55
|
+
// Service URLs if running
|
|
56
|
+
if (isRunning()) {
|
|
57
|
+
console.log('');
|
|
58
|
+
console.log('Service URLs:');
|
|
59
|
+
console.log(' API: http://localhost:54321');
|
|
60
|
+
console.log(' Studio: http://localhost:54323');
|
|
61
|
+
console.log(' Database: localhost:54322');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log('');
|
|
65
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stop command - save state, clear auth tokens, stop Supabase
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Check if Supabase is running
|
|
6
|
+
* 2. Save current database state
|
|
7
|
+
* 3. Clear auth.refresh_tokens (prevents duplicate key errors on next start)
|
|
8
|
+
* 4. Stop Supabase
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { saveState, clearAuthTokens } from '../lib/state.js';
|
|
12
|
+
import { isRunning, shell } from '../lib/docker.js';
|
|
13
|
+
import { log } from '../utils/log.js';
|
|
14
|
+
|
|
15
|
+
export async function stop() {
|
|
16
|
+
// Check if Supabase is running
|
|
17
|
+
if (!isRunning()) {
|
|
18
|
+
log.warn('Supabase is not running');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
log.info('Saving state and stopping Supabase...');
|
|
23
|
+
|
|
24
|
+
// Save current database state
|
|
25
|
+
log.info('Saving local database state...');
|
|
26
|
+
try {
|
|
27
|
+
await saveState();
|
|
28
|
+
log.success('State saved');
|
|
29
|
+
} catch (err) {
|
|
30
|
+
log.error(`Failed to save state: ${err.message}`);
|
|
31
|
+
// Continue anyway - user may want to stop even if save fails
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Clear refresh tokens to prevent duplicate key errors on next start
|
|
35
|
+
log.info('Clearing auth tokens...');
|
|
36
|
+
if (await clearAuthTokens()) {
|
|
37
|
+
log.success('Refresh tokens cleared');
|
|
38
|
+
} else {
|
|
39
|
+
log.warn('Could not clear refresh tokens (this is okay)');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Stop Supabase
|
|
43
|
+
log.info('Stopping Supabase...');
|
|
44
|
+
shell('supabase stop');
|
|
45
|
+
|
|
46
|
+
log.success('Supabase stopped cleanly');
|
|
47
|
+
console.log('');
|
|
48
|
+
console.log('State saved and auth tokens cleared');
|
|
49
|
+
console.log('Next time: supabase-stateful start to restore your session');
|
|
50
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync command - export cloud data and apply to local database
|
|
3
|
+
*
|
|
4
|
+
* Combines export + local application:
|
|
5
|
+
* 1. Export cloud data to seed file
|
|
6
|
+
* 2. Reset local database (applies migrations)
|
|
7
|
+
* 3. Apply the seed data
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawnSync } from 'child_process';
|
|
11
|
+
import { exportCloudData } from '../lib/cloud.js';
|
|
12
|
+
import { isRunning, psqlFile } from '../lib/docker.js';
|
|
13
|
+
import { log } from '../utils/log.js';
|
|
14
|
+
|
|
15
|
+
export async function sync(options) {
|
|
16
|
+
log.info('Syncing cloud data to local...');
|
|
17
|
+
|
|
18
|
+
// Check if Supabase is running
|
|
19
|
+
if (!isRunning()) {
|
|
20
|
+
log.error('Supabase is not running');
|
|
21
|
+
console.log('');
|
|
22
|
+
console.log('Start it first: supabase-stateful start');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Export cloud data
|
|
27
|
+
let seedFile;
|
|
28
|
+
try {
|
|
29
|
+
seedFile = await exportCloudData({
|
|
30
|
+
sample: options.sample,
|
|
31
|
+
tables: options.tables,
|
|
32
|
+
});
|
|
33
|
+
} catch (err) {
|
|
34
|
+
log.error(`Export failed: ${err.message}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Reset local database to apply migrations
|
|
39
|
+
log.info('Resetting local database...');
|
|
40
|
+
const resetResult = spawnSync('supabase', ['db', 'reset'], {
|
|
41
|
+
stdio: 'inherit',
|
|
42
|
+
shell: true,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (resetResult.status !== 0) {
|
|
46
|
+
log.error('Database reset failed');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Apply the seed data
|
|
51
|
+
log.info('Applying seed data...');
|
|
52
|
+
try {
|
|
53
|
+
await psqlFile(seedFile);
|
|
54
|
+
} catch {
|
|
55
|
+
log.warn('Some seed data may have failed (duplicates - this is normal)');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log('');
|
|
59
|
+
log.success('Sync complete!');
|
|
60
|
+
console.log('');
|
|
61
|
+
console.log('Your local database now has cloud data');
|
|
62
|
+
console.log('Access Studio: http://localhost:54323');
|
|
63
|
+
}
|
package/src/lib/cloud.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud data export - pull data from a remote Supabase instance
|
|
3
|
+
*
|
|
4
|
+
* Requires environment variables:
|
|
5
|
+
* - SUPABASE_URL - Your Supabase project URL
|
|
6
|
+
* - SUPABASE_SERVICE_ROLE_KEY - Service role key for data access
|
|
7
|
+
*
|
|
8
|
+
* Based on scouty's data-export.js
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs/promises';
|
|
12
|
+
import { log } from '../utils/log.js';
|
|
13
|
+
|
|
14
|
+
// Tables to export in dependency order (parent tables first)
|
|
15
|
+
const DEFAULT_TABLES = [
|
|
16
|
+
'coaches',
|
|
17
|
+
'client_profiles',
|
|
18
|
+
'products',
|
|
19
|
+
'plans',
|
|
20
|
+
'coach_availability',
|
|
21
|
+
'plan_groups',
|
|
22
|
+
'subscriptions',
|
|
23
|
+
'enquiries',
|
|
24
|
+
'purchases',
|
|
25
|
+
'payments',
|
|
26
|
+
'bookings',
|
|
27
|
+
'reviews',
|
|
28
|
+
'coach_reviews',
|
|
29
|
+
'events',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Export data from cloud Supabase to a seed file
|
|
34
|
+
*/
|
|
35
|
+
export async function exportCloudData(options = {}) {
|
|
36
|
+
const { sample = false, tables = null, output = 'supabase/seed-data.sql' } = options;
|
|
37
|
+
|
|
38
|
+
// Check for required env vars
|
|
39
|
+
const url = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
40
|
+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
41
|
+
|
|
42
|
+
if (!url || !key) {
|
|
43
|
+
log.error('Missing environment variables');
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log('Required:');
|
|
46
|
+
console.log(' SUPABASE_URL (or NEXT_PUBLIC_SUPABASE_URL)');
|
|
47
|
+
console.log(' SUPABASE_SERVICE_ROLE_KEY');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
log.info('Connecting to cloud Supabase...');
|
|
52
|
+
log.dim(`URL: ${url}`);
|
|
53
|
+
|
|
54
|
+
// Determine which tables to export
|
|
55
|
+
const tablesToExport = tables ? tables.split(',').map(t => t.trim()) : DEFAULT_TABLES;
|
|
56
|
+
const limit = sample ? 100 : null;
|
|
57
|
+
|
|
58
|
+
log.info(`Exporting ${tablesToExport.length} tables${sample ? ' (sample: 100 rows each)' : ''}...`);
|
|
59
|
+
|
|
60
|
+
const allData = [];
|
|
61
|
+
|
|
62
|
+
for (const table of tablesToExport) {
|
|
63
|
+
const tableData = await fetchTable(url, key, table, limit);
|
|
64
|
+
if (tableData) {
|
|
65
|
+
allData.push(tableData);
|
|
66
|
+
log.success(` ${table}: ${tableData.data.length} rows`);
|
|
67
|
+
} else {
|
|
68
|
+
log.dim(` ${table}: skipped (not found or empty)`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Generate SQL file
|
|
73
|
+
const sql = generateSql(allData, sample);
|
|
74
|
+
await fs.writeFile(output, sql);
|
|
75
|
+
|
|
76
|
+
const totalRows = allData.reduce((sum, t) => sum + t.data.length, 0);
|
|
77
|
+
log.success(`Exported ${totalRows} total rows to ${output}`);
|
|
78
|
+
|
|
79
|
+
return output;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fetch data from a single table
|
|
84
|
+
*/
|
|
85
|
+
async function fetchTable(url, key, table, limit) {
|
|
86
|
+
try {
|
|
87
|
+
let apiUrl = `${url}/rest/v1/${table}?select=*`;
|
|
88
|
+
if (limit) {
|
|
89
|
+
apiUrl += `&limit=${limit}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const response = await fetch(apiUrl, {
|
|
93
|
+
headers: {
|
|
94
|
+
apikey: key,
|
|
95
|
+
Authorization: `Bearer ${key}`,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const data = await response.json();
|
|
104
|
+
if (!data || data.length === 0) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { table, data };
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Generate SQL INSERT statements from exported data
|
|
116
|
+
*/
|
|
117
|
+
function generateSql(allData, sample) {
|
|
118
|
+
const timestamp = new Date().toISOString();
|
|
119
|
+
|
|
120
|
+
let sql = `-- =============================================================================
|
|
121
|
+
-- Cloud Data Export
|
|
122
|
+
-- =============================================================================
|
|
123
|
+
-- Generated: ${timestamp}
|
|
124
|
+
-- Mode: ${sample ? 'Sample (100 rows per table)' : 'Full export'}
|
|
125
|
+
--
|
|
126
|
+
-- Use this file to seed your local database with cloud data
|
|
127
|
+
-- =============================================================================
|
|
128
|
+
|
|
129
|
+
SET session_replication_role = replica;
|
|
130
|
+
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
for (const { table, data } of allData) {
|
|
134
|
+
if (data.length === 0) continue;
|
|
135
|
+
|
|
136
|
+
const columns = Object.keys(data[0]);
|
|
137
|
+
const columnList = columns.map(c => `"${c}"`).join(', ');
|
|
138
|
+
|
|
139
|
+
sql += `-- ${table}\n`;
|
|
140
|
+
sql += `INSERT INTO public.${table} (${columnList}) VALUES\n`;
|
|
141
|
+
|
|
142
|
+
const valueRows = data.map((row, i) => {
|
|
143
|
+
const values = columns.map(col => formatValue(row[col])).join(', ');
|
|
144
|
+
const comma = i < data.length - 1 ? ',' : '';
|
|
145
|
+
return ` (${values})${comma}`;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
sql += valueRows.join('\n');
|
|
149
|
+
sql += '\nON CONFLICT DO NOTHING;\n\n';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
sql += `SET session_replication_role = DEFAULT;\n`;
|
|
153
|
+
|
|
154
|
+
return sql;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Format a value for SQL INSERT
|
|
159
|
+
*/
|
|
160
|
+
function formatValue(value) {
|
|
161
|
+
if (value === null || value === undefined) return 'NULL';
|
|
162
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
163
|
+
if (typeof value === 'number') return String(value);
|
|
164
|
+
if (typeof value === 'object') {
|
|
165
|
+
return `'${JSON.stringify(value).replace(/'/g, "''")}'::jsonb`;
|
|
166
|
+
}
|
|
167
|
+
// String - escape single quotes
|
|
168
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
169
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration file management
|
|
3
|
+
*
|
|
4
|
+
* Handles the .supabase-stateful.json config file that stores:
|
|
5
|
+
* - stateFile: where to save the database state (default: supabase/local-state.sql)
|
|
6
|
+
* - containerName: the docker container name (e.g., supabase_db_myproject)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs/promises';
|
|
10
|
+
|
|
11
|
+
const CONFIG_FILE = '.supabase-stateful.json';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONFIG = {
|
|
14
|
+
stateFile: 'supabase/local-state.sql',
|
|
15
|
+
containerName: null,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load config from .supabase-stateful.json
|
|
20
|
+
* Falls back to defaults if file doesn't exist
|
|
21
|
+
*/
|
|
22
|
+
export async function getConfig() {
|
|
23
|
+
try {
|
|
24
|
+
const content = await fs.readFile(CONFIG_FILE, 'utf8');
|
|
25
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
|
|
26
|
+
} catch {
|
|
27
|
+
return DEFAULT_CONFIG;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Save config to .supabase-stateful.json
|
|
33
|
+
*/
|
|
34
|
+
export async function saveConfig(config) {
|
|
35
|
+
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if config file exists (used to detect if init has been run)
|
|
40
|
+
*/
|
|
41
|
+
export async function configExists() {
|
|
42
|
+
try {
|
|
43
|
+
await fs.access(CONFIG_FILE);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generic file exists check
|
|
52
|
+
*/
|
|
53
|
+
export async function fileExists(filePath) {
|
|
54
|
+
try {
|
|
55
|
+
await fs.access(filePath);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Append a line to a file if not already present
|
|
64
|
+
* Used to add state file to .gitignore
|
|
65
|
+
*/
|
|
66
|
+
export async function appendIfMissing(filePath, line) {
|
|
67
|
+
try {
|
|
68
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
69
|
+
if (!content.includes(line)) {
|
|
70
|
+
await fs.appendFile(filePath, `\n${line}\n`);
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
await fs.writeFile(filePath, `${line}\n`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker and shell command helpers
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for:
|
|
5
|
+
* - Running commands inside the Supabase postgres container (psql, pg_dump)
|
|
6
|
+
* - Running shell commands (supabase start/stop)
|
|
7
|
+
* - Checking if Supabase containers are running
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync, spawnSync } from 'child_process';
|
|
11
|
+
import { getConfig } from './config.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the Supabase postgres container name from config
|
|
15
|
+
*/
|
|
16
|
+
export async function getContainerName() {
|
|
17
|
+
const config = await getConfig();
|
|
18
|
+
return config.containerName;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run a psql command inside the Supabase postgres container
|
|
23
|
+
* Returns the command output
|
|
24
|
+
*/
|
|
25
|
+
export async function psql(sql) {
|
|
26
|
+
const container = await getContainerName();
|
|
27
|
+
return execSync(
|
|
28
|
+
`docker exec ${container} psql -U postgres -d postgres -c "${sql}"`,
|
|
29
|
+
{ encoding: 'utf8' }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run pg_dump inside the container and return the SQL output
|
|
35
|
+
*/
|
|
36
|
+
export async function pgDump(schemas = ['public', 'auth']) {
|
|
37
|
+
const container = await getContainerName();
|
|
38
|
+
const schemaFlags = schemas.map(s => `--schema=${s}`).join(' ');
|
|
39
|
+
|
|
40
|
+
return execSync(
|
|
41
|
+
`docker exec ${container} pg_dump -U postgres -d postgres --data-only --inserts ${schemaFlags}`,
|
|
42
|
+
{ encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 } // 50MB buffer for large exports
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Copy a file into the container and run psql on it
|
|
48
|
+
*/
|
|
49
|
+
export async function psqlFile(localPath) {
|
|
50
|
+
const container = await getContainerName();
|
|
51
|
+
|
|
52
|
+
// Copy file into container
|
|
53
|
+
execSync(`docker cp "${localPath}" "${container}:/tmp/state.sql"`);
|
|
54
|
+
|
|
55
|
+
// Run psql on the file
|
|
56
|
+
return execSync(
|
|
57
|
+
`docker exec ${container} psql -U postgres -d postgres -f /tmp/state.sql`,
|
|
58
|
+
{ encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 }
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run a shell command with output shown to user
|
|
64
|
+
*/
|
|
65
|
+
export function shell(cmd) {
|
|
66
|
+
return spawnSync(cmd, {
|
|
67
|
+
shell: true,
|
|
68
|
+
stdio: 'inherit',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run a shell command and capture output
|
|
74
|
+
*/
|
|
75
|
+
export function shellCapture(cmd) {
|
|
76
|
+
try {
|
|
77
|
+
return execSync(cmd, { encoding: 'utf8' });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return err.stdout || '';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if Supabase containers are running
|
|
85
|
+
*/
|
|
86
|
+
export function isRunning() {
|
|
87
|
+
try {
|
|
88
|
+
const output = execSync('docker ps --format "{{.Names}}"', {
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
stdio: ['pipe', 'pipe', 'pipe'], // Suppress stderr (Docker not running errors)
|
|
91
|
+
});
|
|
92
|
+
return output.includes('supabase_db_');
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|