@entrinsik/vite-plugin-informer 1.0.5 → 2.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/bin/deploy.js CHANGED
@@ -72,12 +72,12 @@ try {
72
72
  id: savedId
73
73
  });
74
74
 
75
- // Save the report ID back to package.json for future deploys
75
+ // Save the app ID back to package.json for future deploys
76
76
  if (result.id && result.id !== savedId) {
77
77
  if (!pkg.informer) pkg.informer = {};
78
78
  pkg.informer.id = result.id;
79
79
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
80
- console.log(`Saved report ID "${result.id}" to package.json`);
80
+ console.log(`Saved app ID "${result.id}" to package.json`);
81
81
  }
82
82
  } catch (err) {
83
83
  console.error('Deploy failed:', err.message);
package/bin/init.js CHANGED
@@ -232,12 +232,18 @@ async function init() {
232
232
  console.log('Added @entrinsik/vite-plugin-informer to devDependencies');
233
233
  }
234
234
 
235
- // 6. Add deploy script if not present
235
+ // 6. Add scripts if not present
236
236
  if (!pkg.scripts) pkg.scripts = {};
237
237
  if (!pkg.scripts.deploy) {
238
238
  pkg.scripts.deploy = 'npm run build && informer-deploy';
239
239
  console.log('Added "deploy" script to package.json');
240
240
  }
241
+ if (!pkg.scripts['workspace:init']) {
242
+ pkg.scripts['workspace:init'] = 'informer-workspace init';
243
+ pkg.scripts['workspace:migrate'] = 'informer-workspace migrate';
244
+ pkg.scripts['workspace:reset'] = 'informer-workspace reset';
245
+ console.log('Added workspace scripts to package.json');
246
+ }
241
247
 
242
248
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
243
249
  console.log(`Updated package.json with informer.name="${reportName}"`);
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+
3
+ import dotenv from 'dotenv';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { resolve } from 'node:path';
6
+ import { createClient } from '../src/client.js';
7
+ import { init, migrate, reset } from '../src/workspace.js';
8
+
9
+ dotenv.config();
10
+
11
+ const command = process.argv[2];
12
+
13
+ if (!command || !['init', 'migrate', 'reset'].includes(command)) {
14
+ console.error('Usage: informer-workspace <init|migrate|reset>');
15
+ console.error('');
16
+ console.error('Commands:');
17
+ console.error(' init Create a dev workspace datasource and run migrations');
18
+ console.error(' migrate Run pending migrations against the dev workspace');
19
+ console.error(' reset Drop all tables and re-run all migrations');
20
+ process.exit(1);
21
+ }
22
+
23
+ const baseUrl = process.env.INFORMER_URL;
24
+ const apiKey = process.env.INFORMER_API_KEY;
25
+ const user = process.env.INFORMER_USER;
26
+ const pass = process.env.INFORMER_PASS;
27
+
28
+ if (!baseUrl || (!apiKey && (!user || !pass))) {
29
+ console.error('Missing required environment variables.');
30
+ console.error('Set INFORMER_URL and either INFORMER_API_KEY or INFORMER_USER/INFORMER_PASS in .env');
31
+ process.exit(1);
32
+ }
33
+
34
+ const api = createClient({ baseUrl, apiKey, user, pass });
35
+ const migrationsDir = resolve('migrations');
36
+ const envPath = resolve('.env');
37
+
38
+ try {
39
+ if (command === 'init') {
40
+ // Check if already initialized
41
+ if (process.env.INFORMER_DEV_WORKSPACE) {
42
+ console.error(`Workspace already initialized: ${process.env.INFORMER_DEV_WORKSPACE}`);
43
+ console.error('Run workspace:reset to start fresh, or remove INFORMER_DEV_WORKSPACE from .env to re-initialize.');
44
+ process.exit(1);
45
+ }
46
+
47
+ // Read slug from package.json
48
+ const pkgPath = resolve('package.json');
49
+ let pkg;
50
+ try {
51
+ pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
52
+ } catch {
53
+ console.error('Could not read package.json in current directory.');
54
+ process.exit(1);
55
+ }
56
+
57
+ let slug = pkg.name;
58
+ if (slug && slug.startsWith('@') && slug.includes('/')) {
59
+ slug = slug.split('/')[1];
60
+ }
61
+ if (!slug) {
62
+ console.error('package.json must have a "name" field.');
63
+ process.exit(1);
64
+ }
65
+
66
+ await init({ api, slug, migrationsDir, envPath });
67
+
68
+ } else if (command === 'migrate') {
69
+ const workspaceId = process.env.INFORMER_DEV_WORKSPACE;
70
+ if (!workspaceId) {
71
+ console.error('No dev workspace found. Run workspace:init first.');
72
+ process.exit(1);
73
+ }
74
+ await migrate({ api, workspaceId, migrationsDir });
75
+
76
+ } else if (command === 'reset') {
77
+ const workspaceId = process.env.INFORMER_DEV_WORKSPACE;
78
+ if (!workspaceId) {
79
+ console.error('No dev workspace found. Run workspace:init first.');
80
+ process.exit(1);
81
+ }
82
+ await reset({ api, workspaceId, migrationsDir });
83
+ }
84
+ } catch (err) {
85
+ console.error(`workspace:${command} failed:`, err.message);
86
+ process.exit(1);
87
+ }
package/package.json CHANGED
@@ -1,20 +1,35 @@
1
1
  {
2
2
  "name": "@entrinsik/vite-plugin-informer",
3
- "version": "1.0.5",
3
+ "version": "2.1.0",
4
4
  "description": "Vite plugin and publish tool for local Magic Report development",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/entrinsik-org/i5.git",
8
+ "directory": "packages/vite-plugin-informer"
9
+ },
10
+ "publishConfig": {
11
+ "registry": "https://docker.entrinsik.com/repository/entNPM/"
12
+ },
13
+ "author": "Entrinsik Inc.",
14
+ "license": "UNLICENSED",
5
15
  "type": "module",
6
16
  "types": "index.d.ts",
7
17
  "main": "./src/index.js",
18
+ "engines": {
19
+ "node": ">=18.0.0"
20
+ },
8
21
  "exports": {
9
22
  ".": {
10
23
  "types": "./index.d.ts",
11
24
  "default": "./src/index.js"
12
25
  },
13
- "./deploy": "./src/deploy.js"
26
+ "./deploy": "./src/deploy.js",
27
+ "./workspace": "./src/workspace.js"
14
28
  },
15
29
  "bin": {
16
30
  "informer-deploy": "./bin/deploy.js",
17
31
  "informer-init": "./bin/init.js",
32
+ "informer-workspace": "./bin/workspace.js",
18
33
  "create-magic-report": "./bin/init.js"
19
34
  },
20
35
  "files": [
@@ -26,10 +41,6 @@
26
41
  "vite": ">=5.0.0"
27
42
  },
28
43
  "dependencies": {
29
- "dotenv": "^16.4.0"
30
- },
31
- "engines": {
32
- "node": ">=18.0.0"
33
- },
34
- "license": "UNLICENSED"
44
+ "dotenv": "16.6.1"
45
+ }
35
46
  }
package/src/client.js CHANGED
@@ -60,10 +60,11 @@ export function createClient({ baseUrl, apiKey, user, pass }) {
60
60
  }
61
61
 
62
62
  /**
63
- * Upload a file using chunked Flow.js protocol, then assemble into a report's library.
64
- * @param {{ reportId: string, path: string, buffer: Buffer, filename: string }} opts
63
+ * Upload a file using chunked Flow.js protocol, then assemble into an entity's library.
64
+ * @param {{ entityPath?: string, reportId?: string, path: string, buffer: Buffer, filename: string }} opts
65
65
  */
66
- async function uploadChunked({ reportId, path, buffer, filename }) {
66
+ async function uploadChunked({ entityPath, reportId, path, buffer, filename }) {
67
+ const basePath = entityPath || `reports/${reportId}`;
67
68
  const totalChunks = Math.ceil(buffer.length / CHUNK_SIZE);
68
69
  const uploadId = `publish-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
69
70
 
@@ -97,8 +98,8 @@ export function createClient({ baseUrl, apiKey, user, pass }) {
97
98
  }
98
99
  }
99
100
 
100
- // Assemble chunks into a file in the report's library at the specified path
101
- await request('POST', `reports/${reportId}/_upload`, {
101
+ // Assemble chunks into a file in the entity's library at the specified path
102
+ await request('POST', `${basePath}/_upload`, {
102
103
  uploadId,
103
104
  path
104
105
  });
package/src/deploy.js CHANGED
@@ -11,91 +11,115 @@ const TEXT_EXTENSIONS = new Set([
11
11
  const CHUNK_THRESHOLD = 512 * 1024; // 512KB
12
12
 
13
13
  // Config files to upload from project root (if they exist)
14
- const ROOT_CONFIG_FILES = ['data-access.yaml'];
14
+ const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml'];
15
15
 
16
16
  /**
17
- * Deploy a built Vite project to Informer as a Magic Report.
17
+ * Deploy a built Vite project to Informer as an App (or legacy Magic Report).
18
+ *
19
+ * Tries the new /api/apps endpoint first. If the server doesn't support it,
20
+ * falls back to the legacy /api/reports endpoint transparently.
18
21
  *
19
22
  * @param {{ baseUrl: string, apiKey?: string, user?: string, pass?: string, distDir: string, name: string, description?: string, icon?: string, id?: string }} opts
20
- * @returns {Promise<{ id: string, url: string }>} The report's natural ID and URL
23
+ * @returns {Promise<{ id: string, url: string }>} The entity's stable UUID and URL
21
24
  */
22
25
  export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, description, icon, id }) {
23
26
  const api = createClient({ baseUrl, apiKey, user, pass });
24
27
 
25
- let report = null;
28
+ let entity = null;
26
29
  let naturalId = null;
30
+ let apiPrefix = 'apps';
27
31
 
28
- // 1. If we have a saved ID, try to look it up
32
+ // 1. Try to find existing entity apps API first, then legacy reports
29
33
  if (id) {
30
- console.log(`Looking up report "${id}"...`);
31
- report = await api.get(`reports/${id}`);
32
- if (report) {
33
- naturalId = report.naturalId || id;
34
- console.log(`Found existing report: ${naturalId}`);
34
+ console.log(`Looking up "${id}"...`);
35
+ entity = await api.get(`apps/${id}`);
36
+ if (entity) {
37
+ apiPrefix = 'apps';
38
+ naturalId = entity.naturalId || id;
39
+ console.log(`Found existing app: ${naturalId}`);
35
40
  } else {
36
- console.log(`Report "${id}" not found, will create new...`);
41
+ entity = await api.get(`reports/${id}`);
42
+ if (entity) {
43
+ apiPrefix = 'reports';
44
+ naturalId = entity.naturalId || id;
45
+ console.log(`Found existing report: ${naturalId} (legacy API)`);
46
+ } else {
47
+ console.log(`"${id}" not found, will create new...`);
48
+ }
37
49
  }
38
50
  }
39
51
 
40
- // 2. Create if not found (or no ID was provided)
41
- if (!report) {
42
- console.log(`Creating new report "${name}"...`);
52
+ // 2. Create if not found try apps API, fall back to reports
53
+ if (!entity) {
43
54
  const payload = {
44
- type: 'magicReport',
45
55
  name: name,
46
56
  description: description || ''
47
57
  };
48
- // If we have a saved ID (UUID), use it for the new report
49
- if (id) {
50
- payload.id = id;
58
+ if (id) payload.id = id;
59
+
60
+ try {
61
+ console.log(`Creating new app "${name}"...`);
62
+ entity = await api.post('apps', { ...payload, type: 'app' });
63
+ apiPrefix = 'apps';
64
+ } catch (e) {
65
+ // If apps endpoint doesn't exist (404), fall back to legacy reports
66
+ if (e.status === 404) {
67
+ console.log('Apps API not available, using legacy reports API...');
68
+ entity = await api.post('reports', { ...payload, type: 'magicReport' });
69
+ apiPrefix = 'reports';
70
+ } else {
71
+ throw e;
72
+ }
51
73
  }
52
- report = await api.post('reports', payload);
53
- if (!report || !report.id) {
54
- throw new Error(`Failed to create report "${name}"`);
74
+
75
+ if (!entity || !entity.id) {
76
+ throw new Error(`Failed to create "${name}"`);
55
77
  }
56
- naturalId = report.naturalId || `${report.ownerId}:${report.slug}`;
57
- console.log(`Created report: ${naturalId}`);
78
+ naturalId = entity.naturalId || `${entity.ownerId}:${entity.slug}`;
79
+ console.log(`Created: ${naturalId}`);
58
80
  }
59
81
 
60
- // 3. Update name/description/icon if provided (for existing reports)
82
+ const entityPath = `${apiPrefix}/${naturalId}`;
83
+
84
+ // 3. Update name/description/icon if provided (for existing entities)
61
85
  const updates = {};
62
- if (name && report.name !== name) updates.name = name;
63
- if (description !== undefined && report.description !== description) updates.description = description;
86
+ if (name && entity.name !== name) updates.name = name;
87
+ if (description !== undefined && entity.description !== description) updates.description = description;
64
88
  if (icon) {
65
- const currentDefn = report.defn || {};
89
+ const currentDefn = entity.defn || {};
66
90
  if (currentDefn.icon !== icon) {
67
91
  updates.defn = { ...currentDefn, icon };
68
92
  }
69
93
  }
70
94
  if (Object.keys(updates).length > 0) {
71
- await api.put(`reports/${naturalId}`, updates);
95
+ await api.put(entityPath, updates);
72
96
  }
73
97
 
74
- // 5. Snapshot via report route
98
+ // 4. Snapshot
75
99
  console.log('Creating snapshot...');
76
- await api.post(`reports/${naturalId}/snapshots`, {
100
+ await api.post(`${entityPath}/snapshots`, {
77
101
  trigger: 'refresh',
78
102
  limit: 10
79
103
  });
80
104
 
81
- // 6. Clear existing files via report route
105
+ // 5. Clear existing files
82
106
  console.log('Clearing existing files...');
83
- const files = await api.get(`reports/${naturalId}/files?start=0&end=10000`);
107
+ const files = await api.get(`${entityPath}/files?start=0&end=10000`);
84
108
  if (files && Array.isArray(files)) {
85
109
  // Delete non-directories first
86
110
  const nonDirs = files.filter(f => !f.directory);
87
- await Promise.all(nonDirs.map(f => api.del(`reports/${naturalId}/files/${f.id}`)));
111
+ await Promise.all(nonDirs.map(f => api.del(`${entityPath}/files/${f.id}`)));
88
112
 
89
113
  // Then delete directories in reverse order (deepest first by path length)
90
114
  const dirs = files
91
115
  .filter(f => f.directory)
92
116
  .sort((a, b) => (b.path || '').length - (a.path || '').length);
93
117
  for (const d of dirs) {
94
- await api.del(`reports/${naturalId}/files/${d.id}`);
118
+ await api.del(`${entityPath}/files/${d.id}`);
95
119
  }
96
120
  }
97
121
 
98
- // 7. Upload dist/ contents via report routes
122
+ // 6. Upload dist/ contents
99
123
  console.log(`Uploading files from ${distDir}...`);
100
124
  const entries = await walkDir(distDir);
101
125
 
@@ -106,7 +130,7 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
106
130
  if (content.length > CHUNK_THRESHOLD) {
107
131
  // Large file: chunked upload via Flow.js protocol
108
132
  await api.uploadChunked({
109
- reportId: naturalId,
133
+ entityPath,
110
134
  path: relPath,
111
135
  buffer: content,
112
136
  filename: basename(filePath)
@@ -120,12 +144,12 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
120
144
  ? { content: content.toString('utf8'), encoding: 'utf8' }
121
145
  : { content: content.toString('base64'), encoding: 'base64' };
122
146
 
123
- await api.put(`reports/${naturalId}/contents/${relPath}`, payload);
147
+ await api.put(`${entityPath}/contents/${relPath}`, payload);
124
148
  console.log(` ${relPath}`);
125
149
  }
126
150
  }
127
151
 
128
- // 8. Upload config files from project root (e.g., data-access.yaml)
152
+ // 7. Upload config files from project root (e.g., data-access.yaml)
129
153
  const projectRoot = dirname(distDir);
130
154
  let configCount = 0;
131
155
  for (const configFile of ROOT_CONFIG_FILES) {
@@ -133,7 +157,7 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
133
157
  try {
134
158
  await access(configPath);
135
159
  const content = await readFile(configPath, 'utf8');
136
- await api.put(`reports/${naturalId}/contents/${configFile}`, {
160
+ await api.put(`${entityPath}/contents/${configFile}`, {
137
161
  content,
138
162
  encoding: 'utf8'
139
163
  });
@@ -144,13 +168,47 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
144
168
  }
145
169
  }
146
170
 
147
- // 9. Print report URL and return UUID for saving
148
- const totalFiles = entries.length + configCount;
149
- const reportUrl = `${baseUrl.replace(/\/+$/, '')}/reports/r/${naturalId}`;
150
- console.log(`\nPublished ${totalFiles} files to: ${reportUrl}`);
171
+ // 8. Upload migrations/ directory from project root (if it exists)
172
+ const migrationsDir = join(projectRoot, 'migrations');
173
+ let migrationsCount = 0;
174
+ try {
175
+ await access(migrationsDir);
176
+ const migrationFiles = await walkDir(migrationsDir);
177
+ for (const filePath of migrationFiles) {
178
+ const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
179
+ const content = await readFile(filePath, 'utf8');
180
+ await api.put(`${entityPath}/contents/${relPath}`, {
181
+ content,
182
+ encoding: 'utf8'
183
+ });
184
+ console.log(` ${relPath} (from project root)`);
185
+ migrationsCount++;
186
+ }
187
+ } catch {
188
+ // No migrations directory, skip
189
+ }
190
+
191
+ // 9. Run migrations (if the server supports it)
192
+ if (apiPrefix === 'apps') {
193
+ try {
194
+ const result = await api.post(`${entityPath}/_migrate`);
195
+ if (result && result.migrated && result.migrated.length > 0) {
196
+ console.log(`Ran ${result.migrated.length} migration(s): ${result.migrated.join(', ')}`);
197
+ }
198
+ } catch {
199
+ // Server may not support _migrate yet — ignore
200
+ }
201
+ }
202
+
203
+ // 9. Print URL and return UUID for saving
204
+ const totalFiles = entries.length + configCount + migrationsCount;
205
+ const base = baseUrl.replace(/\/+$/, '');
206
+ const entityUrl = apiPrefix === 'apps'
207
+ ? `${base}/api/apps/${naturalId}/view`
208
+ : `${base}/reports/r/${naturalId}`;
209
+ console.log(`\nPublished ${totalFiles} files to: ${entityUrl}`);
151
210
 
152
- // Return the stable UUID (not natural ID) for package.json storage
153
- return { id: report.id, url: reportUrl };
211
+ return { id: entity.id, url: entityUrl };
154
212
  }
155
213
 
156
214
  /**
package/src/index.js CHANGED
@@ -4,6 +4,7 @@ import dotenv from 'dotenv';
4
4
  * Vite plugin for local Magic Report development.
5
5
  *
6
6
  * - Proxies /api requests to the Informer server with Basic auth
7
+ * - Rewrites /api/_query to the dev workspace's _sql route (if configured)
7
8
  * - Injects window.__INFORMER__ context mock in dev mode
8
9
  * - Sets base to './' so built assets use relative paths
9
10
  *
@@ -12,6 +13,9 @@ import dotenv from 'dotenv';
12
13
  */
13
14
  export default function informer(options = {}) {
14
15
  let isDev = false;
16
+ let authHeader = null;
17
+ let serverOrigin = null;
18
+ let devWorkspaceId = null;
15
19
 
16
20
  return {
17
21
  name: 'vite-plugin-informer',
@@ -31,8 +35,11 @@ export default function informer(options = {}) {
31
35
  const user = process.env.INFORMER_USER;
32
36
  const pass = process.env.INFORMER_PASS;
33
37
 
38
+ devWorkspaceId = process.env.INFORMER_DEV_WORKSPACE || null;
39
+
34
40
  if (baseUrl) {
35
- const auth = apiKey
41
+ serverOrigin = baseUrl.replace(/\/+$/, '');
42
+ authHeader = apiKey
36
43
  ? 'Bearer ' + apiKey
37
44
  : 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
38
45
 
@@ -42,7 +49,7 @@ export default function informer(options = {}) {
42
49
  target: baseUrl,
43
50
  changeOrigin: true,
44
51
  headers: {
45
- Authorization: auth
52
+ Authorization: authHeader
46
53
  }
47
54
  }
48
55
  }
@@ -53,6 +60,45 @@ export default function informer(options = {}) {
53
60
  return cfg;
54
61
  },
55
62
 
63
+ configureServer(server) {
64
+ if (!isDev || !devWorkspaceId || !serverOrigin) return;
65
+
66
+ // Intercept POST /api/_query and proxy to the dev workspace's _sql route.
67
+ // This middleware runs before the generic /api proxy, so the rewrite takes effect.
68
+ server.middlewares.use('/api/_query', async (req, res, next) => {
69
+ if (req.method !== 'POST') return next();
70
+
71
+ // Read request body
72
+ const chunks = [];
73
+ for await (const chunk of req) {
74
+ chunks.push(chunk);
75
+ }
76
+ const body = Buffer.concat(chunks).toString('utf8');
77
+
78
+ // Forward to workspace _sql endpoint
79
+ const url = `${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`;
80
+ try {
81
+ const upstream = await fetch(url, {
82
+ method: 'POST',
83
+ headers: {
84
+ 'Content-Type': 'application/json',
85
+ Authorization: authHeader
86
+ },
87
+ body
88
+ });
89
+
90
+ res.statusCode = upstream.status;
91
+ res.setHeader('Content-Type', 'application/json');
92
+ const text = await upstream.text();
93
+ res.end(text);
94
+ } catch (err) {
95
+ res.statusCode = 502;
96
+ res.setHeader('Content-Type', 'application/json');
97
+ res.end(JSON.stringify({ error: err.message }));
98
+ }
99
+ });
100
+ },
101
+
56
102
  transformIndexHtml: {
57
103
  order: 'pre',
58
104
  handler(html) {
@@ -64,6 +110,7 @@ export default function informer(options = {}) {
64
110
  name: 'Local Development'
65
111
  },
66
112
  theme: 'light',
113
+ roles: [],
67
114
  ...options.mock
68
115
  };
69
116
 
@@ -0,0 +1,159 @@
1
+ import { readdir, readFile, writeFile, access } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Execute raw SQL against a workspace datasource via the _sql route.
6
+ */
7
+ async function execSql(api, workspaceId, sql, params = []) {
8
+ return api.post(`datasources/${workspaceId}/_sql`, { sql, params });
9
+ }
10
+
11
+ /**
12
+ * Ensure the _migrations tracking table exists in the workspace.
13
+ */
14
+ async function ensureMigrationsTable(api, workspaceId) {
15
+ await execSql(api, workspaceId, `
16
+ CREATE TABLE IF NOT EXISTS _migrations (
17
+ name TEXT PRIMARY KEY,
18
+ executed_at TIMESTAMPTZ DEFAULT NOW()
19
+ )
20
+ `);
21
+ }
22
+
23
+ /**
24
+ * Read local migration files sorted alphabetically.
25
+ * @returns {Promise<string[]>} Sorted filenames (e.g., ['001-create-orders.sql', '002-add-index.sql'])
26
+ */
27
+ async function readMigrationFiles(migrationsDir) {
28
+ try {
29
+ await access(migrationsDir);
30
+ } catch {
31
+ return [];
32
+ }
33
+
34
+ const entries = await readdir(migrationsDir);
35
+ return entries
36
+ .filter(f => f.endsWith('.sql'))
37
+ .sort();
38
+ }
39
+
40
+ /**
41
+ * Get the set of already-executed migration names.
42
+ */
43
+ async function getCompletedMigrations(api, workspaceId) {
44
+ const result = await execSql(api, workspaceId, 'SELECT name FROM _migrations');
45
+ return new Set(result.rows.map(r => r.name));
46
+ }
47
+
48
+ /**
49
+ * Run pending migrations against a workspace.
50
+ */
51
+ async function runMigrations(api, workspaceId, migrationsDir) {
52
+ await ensureMigrationsTable(api, workspaceId);
53
+
54
+ const files = await readMigrationFiles(migrationsDir);
55
+ if (files.length === 0) {
56
+ console.log('No migration files found in migrations/');
57
+ return { migrated: [], total: 0 };
58
+ }
59
+
60
+ const completed = await getCompletedMigrations(api, workspaceId);
61
+ const pending = files.filter(f => !completed.has(f));
62
+
63
+ if (pending.length === 0) {
64
+ console.log(`All ${files.length} migration(s) already applied.`);
65
+ return { migrated: [], total: files.length };
66
+ }
67
+
68
+ const migrated = [];
69
+ for (const file of pending) {
70
+ const sql = await readFile(join(migrationsDir, file), 'utf8');
71
+ console.log(` Running ${file}...`);
72
+ await execSql(api, workspaceId, sql);
73
+ await execSql(api, workspaceId, 'INSERT INTO _migrations (name) VALUES ($1)', [file]);
74
+ migrated.push(file);
75
+ }
76
+
77
+ console.log(`Applied ${migrated.length} migration(s). Total: ${files.length}`);
78
+ return { migrated, total: files.length };
79
+ }
80
+
81
+ /**
82
+ * Initialize a dev workspace datasource.
83
+ *
84
+ * Creates a new workspace datasource named "{slug}-dev", runs all
85
+ * migrations, and saves the workspace ID to .env.
86
+ */
87
+ export async function init({ api, slug, migrationsDir, envPath }) {
88
+ const name = `${slug}-dev`;
89
+ console.log(`Creating workspace datasource "${name}"...`);
90
+
91
+ const datasource = await api.post('datasources', {
92
+ name,
93
+ type: 'workspace'
94
+ });
95
+
96
+ if (!datasource || !datasource.naturalId) {
97
+ throw new Error('Failed to create workspace datasource');
98
+ }
99
+
100
+ const workspaceId = datasource.naturalId;
101
+ console.log(`Created: ${workspaceId}`);
102
+
103
+ // Run all migrations
104
+ const result = await runMigrations(api, workspaceId, migrationsDir);
105
+
106
+ // Save workspace ID to .env
107
+ let envContent = '';
108
+ try {
109
+ envContent = await readFile(envPath, 'utf8');
110
+ } catch {
111
+ // .env doesn't exist yet
112
+ }
113
+
114
+ if (envContent.includes('INFORMER_DEV_WORKSPACE=')) {
115
+ envContent = envContent.replace(
116
+ /INFORMER_DEV_WORKSPACE=.*/,
117
+ `INFORMER_DEV_WORKSPACE=${workspaceId}`
118
+ );
119
+ } else {
120
+ const nl = envContent.length > 0 && !envContent.endsWith('\n') ? '\n' : '';
121
+ envContent += `${nl}\n# Dev workspace (created by workspace:init)\nINFORMER_DEV_WORKSPACE=${workspaceId}\n`;
122
+ }
123
+
124
+ await writeFile(envPath, envContent);
125
+ console.log(`Saved INFORMER_DEV_WORKSPACE=${workspaceId} to .env`);
126
+
127
+ return { workspaceId, ...result };
128
+ }
129
+
130
+ /**
131
+ * Run pending migrations against the dev workspace.
132
+ */
133
+ export async function migrate({ api, workspaceId, migrationsDir }) {
134
+ console.log(`Migrating workspace ${workspaceId}...`);
135
+ return runMigrations(api, workspaceId, migrationsDir);
136
+ }
137
+
138
+ /**
139
+ * Reset the dev workspace: drop all tables, then re-run all migrations.
140
+ */
141
+ export async function reset({ api, workspaceId, migrationsDir }) {
142
+ console.log(`Resetting workspace ${workspaceId}...`);
143
+
144
+ // Drop all tables in the workspace schema
145
+ await execSql(api, workspaceId, `
146
+ DO $$
147
+ DECLARE r RECORD;
148
+ BEGIN
149
+ FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = current_schema()
150
+ LOOP
151
+ EXECUTE 'DROP TABLE IF EXISTS "' || r.tablename || '" CASCADE';
152
+ END LOOP;
153
+ END $$
154
+ `);
155
+ console.log('Dropped all tables.');
156
+
157
+ // Re-run all migrations from scratch
158
+ return runMigrations(api, workspaceId, migrationsDir);
159
+ }