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.
@@ -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
+ }
@@ -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
+ }