@ductape/cli 0.2.0 → 0.2.1

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.
Files changed (40) hide show
  1. package/.env.ductape.example +5 -0
  2. package/DB_MIGRATE_PLAN.md +223 -0
  3. package/dist/commands/apply.d.ts +6 -0
  4. package/dist/commands/apply.js +71 -0
  5. package/dist/commands/db-migrate.d.ts +19 -0
  6. package/dist/commands/db-migrate.js +154 -0
  7. package/dist/commands/db-schema.d.ts +6 -0
  8. package/dist/commands/db-schema.js +135 -0
  9. package/dist/commands/install.d.ts +1 -1
  10. package/dist/commands/install.js +47 -23
  11. package/dist/index.js +68 -14
  12. package/dist/lib/apply-loaders.d.ts +6 -0
  13. package/dist/lib/apply-loaders.js +24 -0
  14. package/dist/lib/config.d.ts +2 -1
  15. package/dist/lib/config.js +9 -4
  16. package/dist/lib/db-types.d.ts +71 -0
  17. package/dist/lib/db-types.js +1 -0
  18. package/dist/lib/migration-files.d.ts +4 -0
  19. package/dist/lib/migration-files.js +43 -0
  20. package/dist/lib/resources.js +3 -1
  21. package/dist/lib/schema-loader.d.ts +30 -0
  22. package/dist/lib/schema-loader.js +117 -0
  23. package/dist/lib/templates.js +75 -1
  24. package/ductape.example.ts +18 -0
  25. package/package.json +1 -1
  26. package/src/commands/apply.ts +105 -0
  27. package/src/commands/db-migrate.ts +211 -0
  28. package/src/commands/db-schema.ts +163 -0
  29. package/src/commands/install.ts +52 -24
  30. package/src/index.ts +78 -16
  31. package/src/lib/apply-loaders.ts +30 -0
  32. package/src/lib/config.ts +9 -4
  33. package/src/lib/db-types.ts +104 -0
  34. package/src/lib/migration-files.ts +58 -0
  35. package/src/lib/resources.ts +3 -1
  36. package/src/lib/schema-loader.ts +152 -0
  37. package/src/lib/templates.ts +80 -1
  38. package/templates/api-gateway.conf +178 -0
  39. package/templates/docker-compose.release.yml +265 -0
  40. package/templates/platform.env +16 -0
@@ -1,13 +1,37 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { spawnSync, spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { resolvePlatformDir } from '../lib/config.js';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { HUB_PLATFORM_DIR } from '../lib/config.js';
5
6
  import { success } from '../lib/output.js';
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const TEMPLATES_DIR = path.resolve(__dirname, '../../templates');
9
+ const HUB_IMAGES = [
10
+ 'ductape/platform-api:latest',
11
+ 'ductape/platform-workbench:latest',
12
+ ];
6
13
  function checkCommand(cmd, args) {
7
14
  const r = spawnSync(cmd, args, { stdio: 'ignore' });
8
15
  return r.status === 0;
9
16
  }
10
- export function runInstall() {
17
+ function pull(image) {
18
+ return new Promise((resolve, reject) => {
19
+ console.log(`Pulling ${image} …`);
20
+ const child = spawn('docker', ['pull', image], {
21
+ stdio: 'inherit',
22
+ shell: process.platform === 'win32',
23
+ });
24
+ child.on('error', reject);
25
+ child.on('close', (code) => resolve(code ?? 1));
26
+ });
27
+ }
28
+ function copyTemplate(name, dest, overwrite = false) {
29
+ const src = path.join(TEMPLATES_DIR, name);
30
+ if (!overwrite && fs.existsSync(dest))
31
+ return;
32
+ fs.copyFileSync(src, dest);
33
+ }
34
+ export async function runInstall() {
11
35
  const issues = [];
12
36
  if (!checkCommand('docker', ['--version'])) {
13
37
  issues.push('Docker is not installed or not on PATH');
@@ -15,28 +39,28 @@ export function runInstall() {
15
39
  else if (!checkCommand('docker', ['compose', 'version'])) {
16
40
  issues.push('Docker Compose v2 is required (docker compose)');
17
41
  }
18
- if (!checkCommand('node', ['--version'])) {
19
- issues.push('Node.js 18+ is recommended for running the CLI');
20
- }
21
- let platformDir;
22
- try {
23
- platformDir = resolvePlatformDir();
24
- }
25
- catch (e) {
26
- issues.push(e instanceof Error ? e.message : String(e));
27
- platformDir = '';
28
- }
29
- if (platformDir) {
30
- const envExample = path.join(platformDir, '.env.example');
31
- const envFile = path.join(platformDir, '.env');
32
- if (fs.existsSync(envExample) && !fs.existsSync(envFile)) {
33
- fs.copyFileSync(envExample, envFile);
34
- success(`Created ${envFile} from .env.example`);
35
- }
36
- }
37
42
  if (issues.length > 0) {
38
43
  console.error('Install check failed:\n' + issues.map((i) => ` - ${i}`).join('\n'));
39
44
  process.exit(1);
40
45
  }
41
- success('Prerequisites OK. Run `ductape start` to launch the local platform.');
46
+ // Pull platform images from Docker Hub
47
+ for (const image of HUB_IMAGES) {
48
+ const code = await pull(image);
49
+ if (code !== 0) {
50
+ console.error(`Failed to pull ${image} (exit ${code}). Check your internet connection or run \`docker login\`.`);
51
+ process.exit(1);
52
+ }
53
+ }
54
+ // Write platform files to ~/.ductape/platform/
55
+ fs.mkdirSync(HUB_PLATFORM_DIR, { recursive: true });
56
+ copyTemplate('docker-compose.release.yml', path.join(HUB_PLATFORM_DIR, 'docker-compose.yml'), true);
57
+ copyTemplate('api-gateway.conf', path.join(HUB_PLATFORM_DIR, 'api-gateway.conf'), true);
58
+ copyTemplate('platform.env', path.join(HUB_PLATFORM_DIR, '.env'), false); // don't overwrite existing .env
59
+ success(`Platform installed to ${HUB_PLATFORM_DIR}`);
60
+ console.log('');
61
+ console.log(' Edit ~/.ductape/platform/.env to add your AWS credentials (needed for cloud connections).');
62
+ console.log(' Then run: ductape start');
63
+ console.log('');
64
+ console.log(' Workbench: http://localhost:4310');
65
+ console.log(' API: http://localhost:4311');
42
66
  }
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ import { runStart, runStop, runStatus } from './commands/platform.js';
15
15
  import { runResourceCrud, runResourcesList } from './commands/resources.js';
16
16
  import { runCloud } from './commands/cloud.js';
17
17
  import { runDb, runDbContext } from './commands/db.js';
18
+ import { runDbMigrate, runDbMigrateStatus, runDbMigrateRollback } from './commands/db-migrate.js';
19
+ import { runDbSchemaGenerate } from './commands/db-schema.js';
18
20
  import { runGraph } from './commands/graph.js';
19
21
  import { runSecrets } from './commands/secrets.js';
20
22
  import { runWorkspacesCurrent, runWorkspacesList, runWorkspacesRefresh, runWorkspacesUse, } from './commands/workspaces.js';
@@ -23,6 +25,7 @@ import { runCompletion } from './commands/completion.js';
23
25
  import { runProducts, runProductApps } from './commands/products.js';
24
26
  import { runApps } from './commands/apps.js';
25
27
  import { runAppsImport } from './commands/apps-import.js';
28
+ import { runApply } from './commands/apply.js';
26
29
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
30
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '../package.json'), 'utf8'));
28
31
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -140,6 +143,18 @@ program
140
143
  env: opts.env,
141
144
  language: opts.language,
142
145
  })));
146
+ const APPLY_TYPES = ['sessions', 'notifications', 'events'];
147
+ program
148
+ .command('apply')
149
+ .description('Sync declared sessions, notifications and events to your product')
150
+ .argument('[type]', `${APPLY_TYPES.join(' | ')}`)
151
+ .option('--dry-run', 'Print what would be created/updated without applying')
152
+ .action(wrap((type, opts) => {
153
+ if (type && !APPLY_TYPES.includes(type)) {
154
+ throw new Error(`Unknown type "${type}". Use: ${APPLY_TYPES.join(', ')}`);
155
+ }
156
+ return runApply(type, { dryRun: Boolean(opts.dryRun) });
157
+ }));
143
158
  program
144
159
  .command('start')
145
160
  .description('Start platform via docker compose')
@@ -155,10 +170,8 @@ program.command('stop').action(wrap(runStop));
155
170
  program.command('status').option('--json', 'JSON output').action(wrap((opts) => runStatus(Boolean(opts.json))));
156
171
  const products = program
157
172
  .command('products')
158
- .description('Workspace products CRUD (REST + sdk-proxy update)');
159
- products
160
- .command('<verb>')
161
- .description('list | get | create | update | delete')
173
+ .description('Workspace products CRUD (REST + sdk-proxy update)')
174
+ .argument('[verb]', 'list | get | create | update | delete')
162
175
  .option('--profile <name>')
163
176
  .option('--status <status>', 'Filter for list: all, active, draft, …', 'all')
164
177
  .option('--id <id>', 'Product _id (get, delete)')
@@ -167,7 +180,11 @@ products
167
180
  .option('-i, --interactive', 'Prompt for fields (default in TTY when -f omitted)')
168
181
  .option('--no-interactive', 'Require -f JSON for create/update')
169
182
  .option('--json', 'JSON output')
170
- .action(wrap((verb, opts) => runProducts(verb, opts, [])));
183
+ .action(wrap((verb, opts) => {
184
+ if (!verb)
185
+ throw new Error('Specify a verb: list | get | create | update | delete');
186
+ return runProducts(verb, opts, []);
187
+ }));
171
188
  const productApps = products.command('apps').description('Apps connected to a product');
172
189
  productApps
173
190
  .command('list')
@@ -175,10 +192,10 @@ productApps
175
192
  .option('--product <id>', 'Product _id')
176
193
  .option('--json', 'JSON output')
177
194
  .action(wrap((opts) => runProductApps('list', opts, [])));
178
- const apps = program.command('apps').description('Workspace integration apps CRUD (REST)');
179
- apps
180
- .command('<verb>')
181
- .description('list | get | create | update | delete')
195
+ const apps = program
196
+ .command('apps')
197
+ .description('Workspace integration apps CRUD (REST)')
198
+ .argument('[verb]', 'list | get | create | update | delete')
182
199
  .option('--profile <name>')
183
200
  .option('--status <status>', 'Filter for list', 'all')
184
201
  .option('--id <id>', 'App _id')
@@ -188,7 +205,11 @@ apps
188
205
  .option('-i, --interactive', 'Prompt for fields (default in TTY when -f omitted)')
189
206
  .option('--no-interactive', 'Require -f JSON for create/update')
190
207
  .option('--json', 'JSON output')
191
- .action(wrap((verb, opts) => runApps(verb, opts, [])));
208
+ .action(wrap((verb, opts) => {
209
+ if (!verb)
210
+ throw new Error('Specify a verb: list | get | create | update | delete');
211
+ return runApps(verb, opts, []);
212
+ }));
192
213
  apps
193
214
  .command('import <file>')
194
215
  .description('Import Postman v2.1 or OpenAPI 3.0 into a new or existing app')
@@ -211,7 +232,8 @@ apps
211
232
  const resources = program.command('resources').description('Component CRUD (sdk-proxy)');
212
233
  resources.command('types').option('--json', 'JSON output').action(wrap((opts) => runResourcesList(Boolean(opts.json))));
213
234
  resources
214
- .command('<type> <verb>')
235
+ .argument('<type>', 'Resource type (e.g. storage, database, cache …)')
236
+ .argument('<verb>', 'list | get | create | update | delete | connect')
215
237
  .option('-t, --tag <tag>')
216
238
  .option('-f, --file <path>', 'JSON body (or use interactive prompts)')
217
239
  .option('-i, --interactive', 'Prompt for fields (default in TTY when -f omitted)')
@@ -231,14 +253,46 @@ cloud
231
253
  .action(wrap((verb, opts) => runCloud('resources', verb, { file: opts.file, json: opts.json }, [])));
232
254
  const db = program.command('db').description('Database runtime (db-proxy)');
233
255
  db.command('context').option('--json', 'JSON output').action(wrap((opts) => runDbContext(Boolean(opts.json))));
256
+ const dbMigrate = db
257
+ .command('migrate')
258
+ .description('Run pending migrations from ductape/database/migrations/')
259
+ .option('--env <tag>', 'Override environment slug')
260
+ .option('--db <tag>', 'Limit to one db entry from schema.json')
261
+ .option('--dry-run', 'Print what would run without applying')
262
+ .action(wrap((opts) => runDbMigrate({ env: opts.env, db: opts.db, dryRun: Boolean(opts.dryRun) })));
263
+ dbMigrate
264
+ .command('status')
265
+ .description('Show applied vs pending migrations')
266
+ .option('--env <tag>', 'Override environment slug')
267
+ .option('--db <tag>', 'Limit to one db entry')
268
+ .option('--json', 'JSON output')
269
+ .action(wrap((opts) => runDbMigrateStatus({ env: opts.env, db: opts.db, json: Boolean(opts.json) })));
270
+ dbMigrate
271
+ .command('rollback')
272
+ .description('Roll back the last N migrations')
273
+ .option('--env <tag>', 'Override environment slug')
274
+ .option('--db <tag>', 'Limit to one db entry')
275
+ .option('-n <count>', 'Number of migrations to roll back', '1')
276
+ .action(wrap((opts) => runDbMigrateRollback({ env: opts.env, db: opts.db, n: Number(opts.n) })));
277
+ const dbSchema = db.command('schema').description('Schema management for ductape/database/schema.json');
278
+ dbSchema
279
+ .command('generate')
280
+ .description('Diff schema.json vs applied migrations and write new migration files')
281
+ .option('--db <tag>', 'Limit to one db entry from schema.json')
282
+ .option('--destructive', 'Also generate drop migrations for removed fields/tables')
283
+ .action(wrap((opts) => runDbSchemaGenerate({ db: opts.db, destructive: Boolean(opts.destructive) })));
234
284
  db
235
- .command('<verb>')
285
+ .argument('[verb]', 'connect | query | …')
236
286
  .option('-f, --file <path>')
237
287
  .option('--json', 'JSON output')
238
- .action(wrap((verb, opts) => runDb(verb, { file: opts.file, json: opts.json })));
288
+ .action(wrap((verb, opts) => {
289
+ if (!verb)
290
+ throw new Error('Specify a verb: connect | query');
291
+ return runDb(verb, { file: opts.file, json: opts.json });
292
+ }));
239
293
  const graph = program.command('graph').description('Graph runtime (graph-proxy)');
240
294
  graph
241
- .command('<verb>')
295
+ .argument('<verb>', 'connect | query | …')
242
296
  .option('-f, --file <path>')
243
297
  .option('--json', 'JSON output')
244
298
  .action(wrap((verb, opts) => runGraph(verb, { file: opts.file, json: opts.json })));
@@ -0,0 +1,6 @@
1
+ export type ApplyItem = Record<string, unknown> & {
2
+ tag: string;
3
+ };
4
+ export declare function loadSessions(projectDir: string): ApplyItem[] | null;
5
+ export declare function loadNotifications(projectDir: string): ApplyItem[] | null;
6
+ export declare function loadEvents(projectDir: string): ApplyItem[] | null;
@@ -0,0 +1,24 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PROJECT_CONFIG_DIR } from './config.js';
4
+ function loadArrayFile(filePath) {
5
+ if (!fs.existsSync(filePath))
6
+ return null;
7
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
8
+ if (!Array.isArray(raw)) {
9
+ throw new Error(`${path.basename(filePath)} must be a JSON array.`);
10
+ }
11
+ return raw;
12
+ }
13
+ function declarationPath(projectDir, filename) {
14
+ return path.join(projectDir, PROJECT_CONFIG_DIR, filename);
15
+ }
16
+ export function loadSessions(projectDir) {
17
+ return loadArrayFile(declarationPath(projectDir, 'sessions.json'));
18
+ }
19
+ export function loadNotifications(projectDir) {
20
+ return loadArrayFile(declarationPath(projectDir, 'notifications.json'));
21
+ }
22
+ export function loadEvents(projectDir) {
23
+ return loadArrayFile(declarationPath(projectDir, 'events.json'));
24
+ }
@@ -1,7 +1,8 @@
1
1
  export declare const GLOBAL_DIR: string;
2
2
  export declare const CREDENTIALS_PATH: string;
3
3
  export declare const GLOBAL_CONFIG_PATH: string;
4
- export declare const PROJECT_CONFIG_DIR = ".ductape";
4
+ export declare const HUB_PLATFORM_DIR: string;
5
+ export declare const PROJECT_CONFIG_DIR = "ductape";
5
6
  export declare const PROJECT_CONFIG_PATH: string;
6
7
  export interface Credentials {
7
8
  user_id: string;
@@ -6,7 +6,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
6
  export const GLOBAL_DIR = path.join(os.homedir(), '.ductape');
7
7
  export const CREDENTIALS_PATH = path.join(GLOBAL_DIR, 'credentials.json');
8
8
  export const GLOBAL_CONFIG_PATH = path.join(GLOBAL_DIR, 'config.json');
9
- export const PROJECT_CONFIG_DIR = '.ductape';
9
+ export const HUB_PLATFORM_DIR = path.join(GLOBAL_DIR, 'platform');
10
+ export const PROJECT_CONFIG_DIR = 'ductape';
10
11
  export const PROJECT_CONFIG_PATH = path.join(PROJECT_CONFIG_DIR, 'config.json');
11
12
  const DEFAULT_GLOBAL = {
12
13
  default_profile: 'cloud',
@@ -133,7 +134,7 @@ export function loadProjectConfig() {
133
134
  const cfg = { ...found.config };
134
135
  const workspace_id = resolveWorkspaceId(cfg, creds);
135
136
  if (!workspace_id) {
136
- throw new Error('No workspace resolved. Set workspace_tag in .ductape/config.json or run `ductape workspaces use`.');
137
+ throw new Error('No workspace resolved. Set workspace_tag in ductape/config.json or run `ductape workspaces use`.');
137
138
  }
138
139
  return { ...cfg, workspace_id };
139
140
  }
@@ -146,10 +147,14 @@ export function resolvePlatformDir() {
146
147
  if (process.env.DUCTAPE_PLATFORM_DIR) {
147
148
  return path.resolve(process.env.DUCTAPE_PLATFORM_DIR);
148
149
  }
149
- // cli/ -> repo root -> platform/
150
+ // Hub install: ~/.ductape/platform/ (written by `ductape install`)
151
+ if (fs.existsSync(path.join(HUB_PLATFORM_DIR, 'docker-compose.yml'))) {
152
+ return HUB_PLATFORM_DIR;
153
+ }
154
+ // Dev/monorepo: cli/ -> repo root -> platform/
150
155
  const fromPackage = path.resolve(__dirname, '../../../platform');
151
156
  if (fs.existsSync(path.join(fromPackage, 'docker-compose.yml'))) {
152
157
  return fromPackage;
153
158
  }
154
- throw new Error('Platform directory not found. Set DUCTAPE_PLATFORM_DIR to the folder containing docker-compose.yml');
159
+ throw new Error('Platform not installed. Run `ductape install` to pull the platform from Docker Hub.');
155
160
  }
@@ -0,0 +1,71 @@
1
+ export type FieldType = 'string' | 'text' | 'integer' | 'bigint' | 'smallint' | 'decimal' | 'float' | 'double' | 'uuid' | 'boolean' | 'date' | 'time' | 'datetime' | 'timestamp' | 'binary' | 'blob' | 'json' | 'object' | 'array' | 'enum';
2
+ export interface IFieldDefinition {
3
+ name: string;
4
+ type: FieldType;
5
+ nullable?: boolean;
6
+ unique?: boolean;
7
+ primaryKey?: boolean;
8
+ autoGenerate?: boolean;
9
+ defaultValue?: unknown;
10
+ maxLength?: number;
11
+ precision?: number;
12
+ scale?: number;
13
+ enumValues?: string[];
14
+ arrayElementType?: FieldType;
15
+ }
16
+ export interface ICreateCollectionOp {
17
+ type: 'createCollection';
18
+ name: string;
19
+ fields: IFieldDefinition[];
20
+ ifNotExists?: boolean;
21
+ }
22
+ export interface IDropCollectionOp {
23
+ type: 'dropCollection';
24
+ name: string;
25
+ ifExists?: boolean;
26
+ cascade?: boolean;
27
+ }
28
+ export interface IAddFieldOp {
29
+ type: 'addField';
30
+ collection: string;
31
+ field: IFieldDefinition;
32
+ }
33
+ export interface IDropFieldOp {
34
+ type: 'dropField';
35
+ collection: string;
36
+ fieldName: string;
37
+ cascade?: boolean;
38
+ }
39
+ export interface ICreateIndexOp {
40
+ type: 'createIndex';
41
+ collection: string;
42
+ name: string;
43
+ fields: Array<{
44
+ name: string;
45
+ order?: 'asc' | 'desc';
46
+ }>;
47
+ unique?: boolean;
48
+ ifNotExists?: boolean;
49
+ }
50
+ export interface IDropIndexOp {
51
+ type: 'dropIndex';
52
+ collection: string;
53
+ name: string;
54
+ ifExists?: boolean;
55
+ }
56
+ export type IMigrationOperation = ICreateCollectionOp | IDropCollectionOp | IAddFieldOp | IDropFieldOp | ICreateIndexOp | IDropIndexOp;
57
+ export interface IMigration {
58
+ tag: string;
59
+ name: string;
60
+ description?: string;
61
+ up: IMigrationOperation[];
62
+ down: IMigrationOperation[];
63
+ dependencies?: string[];
64
+ createdAt?: string;
65
+ }
66
+ export interface IMigrationHistory {
67
+ tag: string;
68
+ name: string;
69
+ checksum?: string;
70
+ appliedAt?: string;
71
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import type { IMigration } from './db-types.js';
2
+ export declare function getMigrationsDir(projectDir: string, dbTag: string): string;
3
+ export declare function loadMigrationFiles(projectDir: string, dbTag: string): IMigration[];
4
+ export declare function writeMigrationFile(projectDir: string, dbTag: string, migration: IMigration, counter: number): string;
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PROJECT_CONFIG_DIR } from './config.js';
4
+ export function getMigrationsDir(projectDir, dbTag) {
5
+ return path.join(projectDir, PROJECT_CONFIG_DIR, 'database', 'migrations', dbTag);
6
+ }
7
+ export function loadMigrationFiles(projectDir, dbTag) {
8
+ const dir = getMigrationsDir(projectDir, dbTag);
9
+ if (!fs.existsSync(dir))
10
+ return [];
11
+ const files = fs
12
+ .readdirSync(dir)
13
+ .filter((f) => f.endsWith('.json'))
14
+ .sort();
15
+ return files.map((file) => {
16
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
17
+ return raw;
18
+ });
19
+ }
20
+ function makeTimestamp(counter) {
21
+ const now = new Date();
22
+ const pad = (n, len = 2) => String(n).padStart(len, '0');
23
+ const ts = String(now.getFullYear()) +
24
+ pad(now.getMonth() + 1) +
25
+ pad(now.getDate()) +
26
+ pad(now.getHours()) +
27
+ pad(now.getMinutes()) +
28
+ pad(now.getSeconds());
29
+ return `${ts}_${pad(counter, 3)}`;
30
+ }
31
+ export function writeMigrationFile(projectDir, dbTag, migration, counter) {
32
+ const dir = getMigrationsDir(projectDir, dbTag);
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ const safeName = migration.name
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9]+/g, '_')
37
+ .replace(/^_|_$/g, '');
38
+ const ts = makeTimestamp(counter);
39
+ const filename = `${ts}_${safeName}.json`;
40
+ const filePath = path.join(dir, filename);
41
+ fs.writeFileSync(filePath, JSON.stringify(migration, null, 2) + '\n');
42
+ return filePath;
43
+ }
@@ -17,6 +17,8 @@ export const RESOURCE_MODULES = {
17
17
  actions: 'actions',
18
18
  notification: 'notifications',
19
19
  notifications: 'notifications',
20
+ event: 'messageBrokers',
21
+ events: 'messageBrokers',
20
22
  broker: 'messageBrokers',
21
23
  brokers: 'messageBrokers',
22
24
  'message-brokers': 'messageBrokers',
@@ -135,7 +137,7 @@ export function listResourceTypes() {
135
137
  'features',
136
138
  'actions',
137
139
  'notifications',
138
- 'message-brokers',
140
+ 'events',
139
141
  'sessions',
140
142
  'secrets',
141
143
  'quotas',
@@ -0,0 +1,30 @@
1
+ import type { IFieldDefinition } from './db-types.js';
2
+ export interface MongooseFieldDef {
3
+ type?: string | string[];
4
+ required?: boolean;
5
+ unique?: boolean;
6
+ default?: unknown;
7
+ maxlength?: number;
8
+ enum?: string[];
9
+ primaryKey?: boolean;
10
+ autoGenerate?: boolean;
11
+ float?: boolean;
12
+ ref?: string;
13
+ index?: boolean;
14
+ }
15
+ export interface SchemaEntry {
16
+ db: string;
17
+ tables: Record<string, Record<string, MongooseFieldDef>>;
18
+ }
19
+ export declare function getSchemaPath(projectDir: string): string;
20
+ export declare function loadSchemaFile(projectDir: string): SchemaEntry[];
21
+ export declare function parseField(name: string, def: MongooseFieldDef): IFieldDefinition;
22
+ export interface ParsedTable {
23
+ name: string;
24
+ fields: IFieldDefinition[];
25
+ indexFields: Array<{
26
+ fieldName: string;
27
+ unique: boolean;
28
+ }>;
29
+ }
30
+ export declare function parseTableDef(tableName: string, fieldDefs: Record<string, MongooseFieldDef>): ParsedTable;
@@ -0,0 +1,117 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PROJECT_CONFIG_DIR } from './config.js';
4
+ export function getSchemaPath(projectDir) {
5
+ return path.join(projectDir, PROJECT_CONFIG_DIR, 'database', 'schema.json');
6
+ }
7
+ export function loadSchemaFile(projectDir) {
8
+ const schemaPath = getSchemaPath(projectDir);
9
+ if (!fs.existsSync(schemaPath)) {
10
+ throw new Error(`No schema.json found at ${schemaPath}. Run \`ductape init\` to scaffold it.`);
11
+ }
12
+ const raw = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
13
+ if (!Array.isArray(raw)) {
14
+ throw new Error('schema.json must be a JSON array of { db, tables } objects.');
15
+ }
16
+ return raw;
17
+ }
18
+ const TYPE_MAP = {
19
+ string: 'string',
20
+ String: 'string',
21
+ text: 'text',
22
+ Text: 'text',
23
+ number: 'integer',
24
+ Number: 'integer',
25
+ integer: 'integer',
26
+ Integer: 'integer',
27
+ int: 'integer',
28
+ Int: 'integer',
29
+ boolean: 'boolean',
30
+ Boolean: 'boolean',
31
+ bool: 'boolean',
32
+ Bool: 'boolean',
33
+ date: 'datetime',
34
+ Date: 'datetime',
35
+ datetime: 'datetime',
36
+ DateTime: 'datetime',
37
+ timestamp: 'timestamp',
38
+ Timestamp: 'timestamp',
39
+ buffer: 'binary',
40
+ Buffer: 'binary',
41
+ binary: 'binary',
42
+ Binary: 'binary',
43
+ mixed: 'object',
44
+ Mixed: 'object',
45
+ object: 'object',
46
+ Object: 'object',
47
+ array: 'array',
48
+ Array: 'array',
49
+ objectid: 'uuid',
50
+ ObjectId: 'uuid',
51
+ uuid: 'uuid',
52
+ UUID: 'uuid',
53
+ decimal128: 'decimal',
54
+ Decimal128: 'decimal',
55
+ decimal: 'decimal',
56
+ Decimal: 'decimal',
57
+ map: 'object',
58
+ Map: 'object',
59
+ bigint: 'bigint',
60
+ BigInt: 'bigint',
61
+ float: 'float',
62
+ Float: 'float',
63
+ double: 'double',
64
+ Double: 'double',
65
+ json: 'json',
66
+ JSON: 'json',
67
+ blob: 'blob',
68
+ Blob: 'blob',
69
+ smallint: 'smallint',
70
+ SmallInt: 'smallint',
71
+ time: 'time',
72
+ Time: 'time',
73
+ };
74
+ function resolveBaseType(raw, def) {
75
+ const t = Array.isArray(raw) ? raw[0] : (raw ?? 'String');
76
+ if (def.enum?.length)
77
+ return 'enum';
78
+ const mapped = TYPE_MAP[t];
79
+ if (!mapped)
80
+ return 'string';
81
+ if (mapped === 'integer' && def.float)
82
+ return 'float';
83
+ return mapped;
84
+ }
85
+ export function parseField(name, def) {
86
+ const type = resolveBaseType(def.type, def);
87
+ const field = { name, type };
88
+ if (def.required === true)
89
+ field.nullable = false;
90
+ if (def.unique === true)
91
+ field.unique = true;
92
+ if (def.primaryKey === true)
93
+ field.primaryKey = true;
94
+ if (def.autoGenerate === true)
95
+ field.autoGenerate = true;
96
+ if (def.maxlength != null)
97
+ field.maxLength = def.maxlength;
98
+ if (def.default !== undefined) {
99
+ field.defaultValue =
100
+ def.default === 'now' || def.default === 'NOW' ? 'CURRENT_TIMESTAMP' : def.default;
101
+ }
102
+ if (type === 'enum' && def.enum?.length) {
103
+ field.enumValues = def.enum;
104
+ }
105
+ return field;
106
+ }
107
+ export function parseTableDef(tableName, fieldDefs) {
108
+ const fields = [];
109
+ const indexFields = [];
110
+ for (const [name, def] of Object.entries(fieldDefs)) {
111
+ fields.push(parseField(name, def));
112
+ if (def.index === true) {
113
+ indexFields.push({ fieldName: name, unique: def.unique === true });
114
+ }
115
+ }
116
+ return { name: tableName, fields, indexFields };
117
+ }