@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.
- package/.env.ductape.example +5 -0
- package/DB_MIGRATE_PLAN.md +223 -0
- package/dist/commands/apply.d.ts +6 -0
- package/dist/commands/apply.js +71 -0
- package/dist/commands/db-migrate.d.ts +19 -0
- package/dist/commands/db-migrate.js +154 -0
- package/dist/commands/db-schema.d.ts +6 -0
- package/dist/commands/db-schema.js +135 -0
- package/dist/commands/install.d.ts +1 -1
- package/dist/commands/install.js +47 -23
- package/dist/index.js +68 -14
- package/dist/lib/apply-loaders.d.ts +6 -0
- package/dist/lib/apply-loaders.js +24 -0
- package/dist/lib/config.d.ts +2 -1
- package/dist/lib/config.js +9 -4
- package/dist/lib/db-types.d.ts +71 -0
- package/dist/lib/db-types.js +1 -0
- package/dist/lib/migration-files.d.ts +4 -0
- package/dist/lib/migration-files.js +43 -0
- package/dist/lib/resources.js +3 -1
- package/dist/lib/schema-loader.d.ts +30 -0
- package/dist/lib/schema-loader.js +117 -0
- package/dist/lib/templates.js +75 -1
- package/ductape.example.ts +18 -0
- package/package.json +1 -1
- package/src/commands/apply.ts +105 -0
- package/src/commands/db-migrate.ts +211 -0
- package/src/commands/db-schema.ts +163 -0
- package/src/commands/install.ts +52 -24
- package/src/index.ts +78 -16
- package/src/lib/apply-loaders.ts +30 -0
- package/src/lib/config.ts +9 -4
- package/src/lib/db-types.ts +104 -0
- package/src/lib/migration-files.ts +58 -0
- package/src/lib/resources.ts +3 -1
- package/src/lib/schema-loader.ts +152 -0
- package/src/lib/templates.ts +80 -1
- package/templates/api-gateway.conf +178 -0
- package/templates/docker-compose.release.yml +265 -0
- package/templates/platform.env +16 -0
package/dist/lib/templates.js
CHANGED
|
@@ -47,7 +47,7 @@ DUCTAPE_ENV=dev
|
|
|
47
47
|
const envPath = path.join(dir, '.env.ductape.example');
|
|
48
48
|
if (!fs.existsSync(envPath))
|
|
49
49
|
fs.writeFileSync(envPath, envContent);
|
|
50
|
-
const ductapeDir = path.join(dir, '
|
|
50
|
+
const ductapeDir = path.join(dir, 'ductape');
|
|
51
51
|
fs.mkdirSync(ductapeDir, { recursive: true });
|
|
52
52
|
const readme = `# Ductape project
|
|
53
53
|
|
|
@@ -63,6 +63,80 @@ Docs: https://docs.ductape.app/docs/cli/
|
|
|
63
63
|
const readmePath = path.join(ductapeDir, 'README.md');
|
|
64
64
|
if (!fs.existsSync(readmePath))
|
|
65
65
|
fs.writeFileSync(readmePath, readme);
|
|
66
|
+
const dbDir = path.join(ductapeDir, 'database');
|
|
67
|
+
const migrationsDir = path.join(dbDir, 'migrations');
|
|
68
|
+
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
69
|
+
const schemaPath = path.join(dbDir, 'schema.json');
|
|
70
|
+
if (!fs.existsSync(schemaPath)) {
|
|
71
|
+
const schemaTemplate = [
|
|
72
|
+
{
|
|
73
|
+
db: 'your_db_tag',
|
|
74
|
+
tables: {
|
|
75
|
+
example_table: {
|
|
76
|
+
id: { type: 'String', primaryKey: true, autoGenerate: true },
|
|
77
|
+
name: { type: 'String', required: true },
|
|
78
|
+
createdAt: { type: 'Date', default: 'now' },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
fs.writeFileSync(schemaPath, JSON.stringify(schemaTemplate, null, 2) + '\n');
|
|
84
|
+
}
|
|
85
|
+
const sessionsPath = path.join(ductapeDir, 'sessions.json');
|
|
86
|
+
if (!fs.existsSync(sessionsPath)) {
|
|
87
|
+
const sessionsTemplate = [
|
|
88
|
+
{
|
|
89
|
+
tag: 'user-session',
|
|
90
|
+
name: 'User session',
|
|
91
|
+
description: 'Standard authenticated user session',
|
|
92
|
+
expiry: 604800,
|
|
93
|
+
refresh_expiry: 2592000,
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
fs.writeFileSync(sessionsPath, JSON.stringify(sessionsTemplate, null, 2) + '\n');
|
|
97
|
+
}
|
|
98
|
+
const notificationsPath = path.join(ductapeDir, 'notifications.json');
|
|
99
|
+
if (!fs.existsSync(notificationsPath)) {
|
|
100
|
+
const notificationsTemplate = [
|
|
101
|
+
{
|
|
102
|
+
tag: 'transactional-email',
|
|
103
|
+
name: 'Transactional email',
|
|
104
|
+
description: 'Email channel for transactional messages',
|
|
105
|
+
envs: [
|
|
106
|
+
{
|
|
107
|
+
slug: 'dev',
|
|
108
|
+
emails: {
|
|
109
|
+
host: 'smtp.example.com',
|
|
110
|
+
port: 587,
|
|
111
|
+
user: 'your-smtp-user',
|
|
112
|
+
pass: 'your-smtp-pass',
|
|
113
|
+
sender: 'no-reply@example.com',
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
fs.writeFileSync(notificationsPath, JSON.stringify(notificationsTemplate, null, 2) + '\n');
|
|
120
|
+
}
|
|
121
|
+
const eventsPath = path.join(ductapeDir, 'events.json');
|
|
122
|
+
if (!fs.existsSync(eventsPath)) {
|
|
123
|
+
const eventsTemplate = [
|
|
124
|
+
{
|
|
125
|
+
tag: 'main-event-bus',
|
|
126
|
+
name: 'Main event bus',
|
|
127
|
+
description: 'Primary message broker for application events',
|
|
128
|
+
envs: [
|
|
129
|
+
{
|
|
130
|
+
slug: 'dev',
|
|
131
|
+
type: 'kafka',
|
|
132
|
+
brokers: ['localhost:9092'],
|
|
133
|
+
client_id: 'my-app-dev',
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
fs.writeFileSync(eventsPath, JSON.stringify(eventsTemplate, null, 2) + '\n');
|
|
139
|
+
}
|
|
66
140
|
copyTemplate(lang, dir);
|
|
67
141
|
const gitignorePath = path.join(dir, '.gitignore');
|
|
68
142
|
if (fs.existsSync(gitignorePath)) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ductape SDK example — product/env defaults are set on the constructor only.
|
|
3
|
+
* CLI: `ductape login` + `ductape link` (see .ductape/config.json).
|
|
4
|
+
*/
|
|
5
|
+
import Ductape from '@ductape/sdk';
|
|
6
|
+
|
|
7
|
+
const ductape = new Ductape({
|
|
8
|
+
accessKey: process.env.DUCTAPE_ACCESS_KEY!,
|
|
9
|
+
product: process.env.DUCTAPE_PRODUCT!,
|
|
10
|
+
env: process.env.DUCTAPE_ENV ?? 'dev',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
// await ductape.databases.connect({ database: 'main-db' });
|
|
15
|
+
console.log('Ductape client ready');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
main().catch(console.error);
|
package/package.json
CHANGED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { findProjectConfig } from '../lib/config.js';
|
|
2
|
+
import { getSdkProxy, requireSession } from '../lib/proxy/context.js';
|
|
3
|
+
import { fail } from '../lib/output.js';
|
|
4
|
+
import { buildCrudParams } from '../lib/resources.js';
|
|
5
|
+
import {
|
|
6
|
+
loadSessions,
|
|
7
|
+
loadNotifications,
|
|
8
|
+
loadEvents,
|
|
9
|
+
type ApplyItem,
|
|
10
|
+
} from '../lib/apply-loaders.js';
|
|
11
|
+
import type { SDKModule } from '../lib/proxy/sdk-proxy.js';
|
|
12
|
+
|
|
13
|
+
export type ApplyType = 'sessions' | 'notifications' | 'events';
|
|
14
|
+
|
|
15
|
+
interface ApplyOpts {
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MODULE_FOR: Record<ApplyType, SDKModule> = {
|
|
20
|
+
sessions: 'sessions',
|
|
21
|
+
notifications: 'notifications',
|
|
22
|
+
events: 'messageBrokers',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
async function syncItems(
|
|
26
|
+
type: ApplyType,
|
|
27
|
+
items: ApplyItem[],
|
|
28
|
+
productTag: string,
|
|
29
|
+
proxy: ReturnType<typeof getSdkProxy>,
|
|
30
|
+
dryRun: boolean,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const module = MODULE_FOR[type];
|
|
33
|
+
|
|
34
|
+
const listResult = await proxy.execute<unknown>(module, 'list', buildCrudParams(module, 'list', productTag, {}));
|
|
35
|
+
const existingList = Array.isArray(listResult) ? listResult : [];
|
|
36
|
+
const existingTags = new Set(
|
|
37
|
+
existingList.map((r) => (r as { tag?: string }).tag ?? '').filter(Boolean),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
if (!item.tag) {
|
|
42
|
+
console.warn(` [${type}] skipped — missing "tag" field`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const verb: 'create' | 'update' = existingTags.has(item.tag) ? 'update' : 'create';
|
|
47
|
+
|
|
48
|
+
if (dryRun) {
|
|
49
|
+
console.log(` [${type}] would ${verb}: ${item.tag}`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const params = buildCrudParams(module, verb, productTag, { tag: item.tag, body: item });
|
|
55
|
+
await proxy.execute(module, verb, params);
|
|
56
|
+
console.log(` [${type}] ${verb}d: ${item.tag}`);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error(
|
|
59
|
+
` [${type}] ${verb} failed for "${item.tag}": ${err instanceof Error ? err.message : String(err)}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function runApply(type: ApplyType | undefined, opts: ApplyOpts): Promise<void> {
|
|
66
|
+
const found = findProjectConfig();
|
|
67
|
+
if (!found) fail('No linked project. Run `ductape link` from your project directory.');
|
|
68
|
+
|
|
69
|
+
const { dir } = found;
|
|
70
|
+
const session = requireSession();
|
|
71
|
+
const proxy = getSdkProxy(session);
|
|
72
|
+
const productTag = session.project.product_tag;
|
|
73
|
+
const dryRun = Boolean(opts.dryRun);
|
|
74
|
+
|
|
75
|
+
const targets: ApplyType[] = type ? [type] : ['sessions', 'notifications', 'events'];
|
|
76
|
+
|
|
77
|
+
const loaders: Record<ApplyType, () => ApplyItem[] | null> = {
|
|
78
|
+
sessions: () => loadSessions(dir),
|
|
79
|
+
notifications: () => loadNotifications(dir),
|
|
80
|
+
events: () => loadEvents(dir),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
for (const t of targets) {
|
|
84
|
+
let items: ApplyItem[] | null;
|
|
85
|
+
try {
|
|
86
|
+
items = loaders[t]();
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error(`[${t}] parse error: ${err instanceof Error ? err.message : String(err)}`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (items === null) {
|
|
93
|
+
console.log(`[${t}] no ductape/${t}.json found — skipping`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (items.length === 0) {
|
|
98
|
+
console.log(`[${t}] empty — nothing to apply`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(`[${t}] applying ${items.length} item(s)${dryRun ? ' (dry run)' : ''}...`);
|
|
103
|
+
await syncItems(t, items, productTag, proxy, dryRun);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { findProjectConfig } from '../lib/config.js';
|
|
2
|
+
import { loadSchemaFile } from '../lib/schema-loader.js';
|
|
3
|
+
import { loadMigrationFiles } from '../lib/migration-files.js';
|
|
4
|
+
import { getDbProxy, requireSession } from '../lib/proxy/context.js';
|
|
5
|
+
import { fail, printJson } from '../lib/output.js';
|
|
6
|
+
import type { DatabaseContext } from '../lib/context-store.js';
|
|
7
|
+
|
|
8
|
+
interface MigrateOpts {
|
|
9
|
+
env?: string;
|
|
10
|
+
db?: string;
|
|
11
|
+
dryRun?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface MigrateStatusOpts {
|
|
15
|
+
env?: string;
|
|
16
|
+
db?: string;
|
|
17
|
+
json?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface MigrateRollbackOpts {
|
|
21
|
+
env?: string;
|
|
22
|
+
db?: string;
|
|
23
|
+
n?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildDbContext(dbTag: string, envSlug: string, productTag: string): DatabaseContext {
|
|
27
|
+
return { database: dbTag, env: envSlug, product: productTag };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function getAppliedTags(
|
|
31
|
+
proxy: ReturnType<typeof getDbProxy>,
|
|
32
|
+
dbContext: DatabaseContext,
|
|
33
|
+
productTag: string,
|
|
34
|
+
envSlug: string,
|
|
35
|
+
): Promise<string[]> {
|
|
36
|
+
try {
|
|
37
|
+
const result = await proxy.execute<unknown>(
|
|
38
|
+
'migration.list',
|
|
39
|
+
[{ product: productTag, env: envSlug, database: dbContext.database }],
|
|
40
|
+
dbContext,
|
|
41
|
+
);
|
|
42
|
+
const list = Array.isArray(result)
|
|
43
|
+
? result
|
|
44
|
+
: Array.isArray((result as { data?: unknown[] }).data)
|
|
45
|
+
? (result as { data: unknown[] }).data
|
|
46
|
+
: [];
|
|
47
|
+
return (list as Array<{ tag?: string }>).map((r) => r.tag ?? '').filter(Boolean);
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runDbMigrate(opts: MigrateOpts): Promise<void> {
|
|
54
|
+
const found = findProjectConfig();
|
|
55
|
+
if (!found) fail('No linked project. Run `ductape link` from your project directory.');
|
|
56
|
+
|
|
57
|
+
const { dir } = found;
|
|
58
|
+
const session = requireSession();
|
|
59
|
+
const proxy = getDbProxy(session);
|
|
60
|
+
const envSlug = opts.env ?? session.project.env_slug;
|
|
61
|
+
const productTag = session.project.product_tag;
|
|
62
|
+
|
|
63
|
+
const schemaEntries = loadSchemaFile(dir);
|
|
64
|
+
const targets = opts.db ? schemaEntries.filter((e) => e.db === opts.db) : schemaEntries;
|
|
65
|
+
|
|
66
|
+
if (targets.length === 0) {
|
|
67
|
+
fail(opts.db ? `No db entry found for tag "${opts.db}".` : 'schema.json is empty.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let totalApplied = 0;
|
|
71
|
+
let totalDry = 0;
|
|
72
|
+
|
|
73
|
+
for (const entry of targets) {
|
|
74
|
+
const { db } = entry;
|
|
75
|
+
const dbContext = buildDbContext(db, envSlug, productTag);
|
|
76
|
+
const migrations = loadMigrationFiles(dir, db);
|
|
77
|
+
|
|
78
|
+
if (migrations.length === 0) {
|
|
79
|
+
console.log(`[${db}] No migration files found. Run \`ductape db schema generate\` first.`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const appliedTags = await getAppliedTags(proxy, dbContext, productTag, envSlug);
|
|
84
|
+
const pending = migrations.filter((m) => !appliedTags.includes(m.tag));
|
|
85
|
+
|
|
86
|
+
if (pending.length === 0) {
|
|
87
|
+
console.log(`[${db}] Already up to date.`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(`[${db}] ${pending.length} pending migration(s).`);
|
|
92
|
+
|
|
93
|
+
for (const migration of pending) {
|
|
94
|
+
if (opts.dryRun) {
|
|
95
|
+
console.log(` [dry-run] Would apply: ${migration.tag}`);
|
|
96
|
+
totalDry++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
process.stdout.write(` Applying ${migration.tag}... `);
|
|
101
|
+
try {
|
|
102
|
+
await proxy.execute(
|
|
103
|
+
'migration.run',
|
|
104
|
+
[{ product: productTag, env: envSlug, database: db, migrations: [migration] }],
|
|
105
|
+
dbContext,
|
|
106
|
+
);
|
|
107
|
+
console.log('done');
|
|
108
|
+
totalApplied++;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.log('failed');
|
|
111
|
+
fail(`Migration "${migration.tag}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (opts.dryRun) {
|
|
117
|
+
console.log(`\nDry run: ${totalDry} migration(s) would be applied.`);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(`\nApplied ${totalApplied} migration(s).`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function runDbMigrateStatus(opts: MigrateStatusOpts): Promise<void> {
|
|
124
|
+
const found = findProjectConfig();
|
|
125
|
+
if (!found) fail('No linked project. Run `ductape link` from your project directory.');
|
|
126
|
+
|
|
127
|
+
const { dir } = found;
|
|
128
|
+
const session = requireSession();
|
|
129
|
+
const proxy = getDbProxy(session);
|
|
130
|
+
const envSlug = opts.env ?? session.project.env_slug;
|
|
131
|
+
const productTag = session.project.product_tag;
|
|
132
|
+
|
|
133
|
+
const schemaEntries = loadSchemaFile(dir);
|
|
134
|
+
const targets = opts.db ? schemaEntries.filter((e) => e.db === opts.db) : schemaEntries;
|
|
135
|
+
|
|
136
|
+
const statusReport: Record<string, { applied: string[]; pending: string[] }> = {};
|
|
137
|
+
|
|
138
|
+
for (const entry of targets) {
|
|
139
|
+
const { db } = entry;
|
|
140
|
+
const dbContext = buildDbContext(db, envSlug, productTag);
|
|
141
|
+
const migrations = loadMigrationFiles(dir, db);
|
|
142
|
+
const appliedTags = await getAppliedTags(proxy, dbContext, productTag, envSlug);
|
|
143
|
+
|
|
144
|
+
const applied = migrations.filter((m) => appliedTags.includes(m.tag)).map((m) => m.tag);
|
|
145
|
+
const pending = migrations.filter((m) => !appliedTags.includes(m.tag)).map((m) => m.tag);
|
|
146
|
+
|
|
147
|
+
statusReport[db] = { applied, pending };
|
|
148
|
+
|
|
149
|
+
if (!opts.json) {
|
|
150
|
+
console.log(`\n[${db}]`);
|
|
151
|
+
if (applied.length > 0) {
|
|
152
|
+
console.log(` Applied (${applied.length}):`);
|
|
153
|
+
for (const t of applied) console.log(` + ${t}`);
|
|
154
|
+
}
|
|
155
|
+
if (pending.length > 0) {
|
|
156
|
+
console.log(` Pending (${pending.length}):`);
|
|
157
|
+
for (const t of pending) console.log(` - ${t}`);
|
|
158
|
+
}
|
|
159
|
+
if (applied.length === 0 && pending.length === 0) {
|
|
160
|
+
console.log(' No migration files found.');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (opts.json) printJson(statusReport, true);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function runDbMigrateRollback(opts: MigrateRollbackOpts): Promise<void> {
|
|
169
|
+
const found = findProjectConfig();
|
|
170
|
+
if (!found) fail('No linked project. Run `ductape link` from your project directory.');
|
|
171
|
+
|
|
172
|
+
const { dir } = found;
|
|
173
|
+
const session = requireSession();
|
|
174
|
+
const proxy = getDbProxy(session);
|
|
175
|
+
const envSlug = opts.env ?? session.project.env_slug;
|
|
176
|
+
const productTag = session.project.product_tag;
|
|
177
|
+
const rollbackCount = opts.n ?? 1;
|
|
178
|
+
|
|
179
|
+
const schemaEntries = loadSchemaFile(dir);
|
|
180
|
+
const targets = opts.db ? schemaEntries.filter((e) => e.db === opts.db) : schemaEntries;
|
|
181
|
+
|
|
182
|
+
for (const entry of targets) {
|
|
183
|
+
const { db } = entry;
|
|
184
|
+
const dbContext = buildDbContext(db, envSlug, productTag);
|
|
185
|
+
const migrations = loadMigrationFiles(dir, db);
|
|
186
|
+
const appliedTags = await getAppliedTags(proxy, dbContext, productTag, envSlug);
|
|
187
|
+
|
|
188
|
+
const applied = migrations.filter((m) => appliedTags.includes(m.tag));
|
|
189
|
+
const toRollback = applied.slice(-rollbackCount).reverse();
|
|
190
|
+
|
|
191
|
+
if (toRollback.length === 0) {
|
|
192
|
+
console.log(`[${db}] Nothing to roll back.`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const migration of toRollback) {
|
|
197
|
+
process.stdout.write(` Rolling back ${migration.tag}... `);
|
|
198
|
+
try {
|
|
199
|
+
await proxy.execute(
|
|
200
|
+
'migration.rollback',
|
|
201
|
+
[{ product: productTag, env: envSlug, database: db, migrations: [migration] }],
|
|
202
|
+
dbContext,
|
|
203
|
+
);
|
|
204
|
+
console.log('done');
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.log('failed');
|
|
207
|
+
fail(`Rollback of "${migration.tag}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { findProjectConfig } from '../lib/config.js';
|
|
3
|
+
import { loadSchemaFile, parseTableDef } from '../lib/schema-loader.js';
|
|
4
|
+
import { loadMigrationFiles, writeMigrationFile } from '../lib/migration-files.js';
|
|
5
|
+
import { fail } from '../lib/output.js';
|
|
6
|
+
import type {
|
|
7
|
+
IMigration,
|
|
8
|
+
IMigrationOperation,
|
|
9
|
+
ICreateCollectionOp,
|
|
10
|
+
IDropCollectionOp,
|
|
11
|
+
IAddFieldOp,
|
|
12
|
+
ICreateIndexOp,
|
|
13
|
+
} from '../lib/db-types.js';
|
|
14
|
+
|
|
15
|
+
interface SchemaGenerateOpts {
|
|
16
|
+
db?: string;
|
|
17
|
+
destructive?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ReconstructedTable {
|
|
21
|
+
fields: Set<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function replayMigrations(migrations: IMigration[]): Map<string, ReconstructedTable> {
|
|
25
|
+
const schema = new Map<string, ReconstructedTable>();
|
|
26
|
+
for (const migration of migrations) {
|
|
27
|
+
for (const op of migration.up) {
|
|
28
|
+
if (op.type === 'createCollection') {
|
|
29
|
+
if (!schema.has(op.name)) {
|
|
30
|
+
schema.set(op.name, { fields: new Set(op.fields.map((f) => f.name)) });
|
|
31
|
+
}
|
|
32
|
+
} else if (op.type === 'dropCollection') {
|
|
33
|
+
schema.delete(op.name);
|
|
34
|
+
} else if (op.type === 'addField') {
|
|
35
|
+
const t = schema.get(op.collection);
|
|
36
|
+
if (t) t.fields.add(op.field.name);
|
|
37
|
+
} else if (op.type === 'dropField') {
|
|
38
|
+
const t = schema.get(op.collection);
|
|
39
|
+
if (t) t.fields.delete(op.fieldName);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return schema;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runDbSchemaGenerate(opts: SchemaGenerateOpts): Promise<void> {
|
|
47
|
+
const found = findProjectConfig();
|
|
48
|
+
if (!found) fail('No linked project. Run `ductape link` from your project directory.');
|
|
49
|
+
|
|
50
|
+
const { dir } = found;
|
|
51
|
+
const schemaEntries = loadSchemaFile(dir);
|
|
52
|
+
const targets = opts.db
|
|
53
|
+
? schemaEntries.filter((e) => e.db === opts.db)
|
|
54
|
+
: schemaEntries;
|
|
55
|
+
|
|
56
|
+
if (targets.length === 0) {
|
|
57
|
+
fail(opts.db ? `No db entry found for tag "${opts.db}".` : 'schema.json is empty.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let totalGenerated = 0;
|
|
61
|
+
|
|
62
|
+
for (const entry of targets) {
|
|
63
|
+
const { db, tables } = entry;
|
|
64
|
+
const existing = loadMigrationFiles(dir, db);
|
|
65
|
+
const current = replayMigrations(existing);
|
|
66
|
+
const newMigrations: IMigration[] = [];
|
|
67
|
+
let counter = existing.length + 1;
|
|
68
|
+
|
|
69
|
+
for (const [tableName, fieldDefs] of Object.entries(tables)) {
|
|
70
|
+
const parsed = parseTableDef(tableName, fieldDefs);
|
|
71
|
+
const currentTable = current.get(tableName);
|
|
72
|
+
|
|
73
|
+
if (!currentTable) {
|
|
74
|
+
const up: IMigrationOperation[] = [
|
|
75
|
+
{
|
|
76
|
+
type: 'createCollection',
|
|
77
|
+
name: tableName,
|
|
78
|
+
fields: parsed.fields,
|
|
79
|
+
ifNotExists: true,
|
|
80
|
+
} as ICreateCollectionOp,
|
|
81
|
+
];
|
|
82
|
+
for (const { fieldName, unique } of parsed.indexFields) {
|
|
83
|
+
up.push({
|
|
84
|
+
type: 'createIndex',
|
|
85
|
+
collection: tableName,
|
|
86
|
+
name: `${tableName}_${fieldName}_idx`,
|
|
87
|
+
fields: [{ name: fieldName }],
|
|
88
|
+
unique,
|
|
89
|
+
ifNotExists: true,
|
|
90
|
+
} as ICreateIndexOp);
|
|
91
|
+
}
|
|
92
|
+
const down: IMigrationOperation[] = [
|
|
93
|
+
{ type: 'dropCollection', name: tableName, ifExists: true } as IDropCollectionOp,
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
newMigrations.push({
|
|
97
|
+
tag: `create_${tableName}`,
|
|
98
|
+
name: `Create collection ${tableName}`,
|
|
99
|
+
up,
|
|
100
|
+
down,
|
|
101
|
+
createdAt: new Date().toISOString(),
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
const addOps: IMigrationOperation[] = [];
|
|
105
|
+
const dropOps: IMigrationOperation[] = [];
|
|
106
|
+
|
|
107
|
+
for (const field of parsed.fields) {
|
|
108
|
+
if (!currentTable.fields.has(field.name)) {
|
|
109
|
+
addOps.push({ type: 'addField', collection: tableName, field } as IAddFieldOp);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const desiredFields = new Set(parsed.fields.map((f) => f.name));
|
|
114
|
+
const removedFields: string[] = [];
|
|
115
|
+
for (const existingField of currentTable.fields) {
|
|
116
|
+
if (!desiredFields.has(existingField)) {
|
|
117
|
+
removedFields.push(existingField);
|
|
118
|
+
if (opts.destructive) {
|
|
119
|
+
dropOps.push({ type: 'dropField', collection: tableName, fieldName: existingField });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!opts.destructive && removedFields.length > 0) {
|
|
124
|
+
console.warn(
|
|
125
|
+
` [${db}] Warning: field(s) removed from schema.json for "${tableName}": ${removedFields.join(', ')}. Pass --destructive to generate drop migrations.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const allOps = [...addOps, ...dropOps];
|
|
130
|
+
if (allOps.length > 0) {
|
|
131
|
+
const down: IMigrationOperation[] = addOps.map((op) => ({
|
|
132
|
+
type: 'dropField' as const,
|
|
133
|
+
collection: tableName,
|
|
134
|
+
fieldName: (op as IAddFieldOp).field.name,
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
newMigrations.push({
|
|
138
|
+
tag: `alter_${tableName}_${Date.now()}`,
|
|
139
|
+
name: `Alter collection ${tableName}`,
|
|
140
|
+
up: allOps,
|
|
141
|
+
down,
|
|
142
|
+
createdAt: new Date().toISOString(),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (newMigrations.length === 0) {
|
|
149
|
+
console.log(`[${db}] Schema is up to date. No migrations generated.`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const migration of newMigrations) {
|
|
154
|
+
const filePath = writeMigrationFile(dir, db, migration, counter++);
|
|
155
|
+
console.log(`[${db}] Generated ${path.basename(filePath)}`);
|
|
156
|
+
totalGenerated++;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (totalGenerated > 0) {
|
|
161
|
+
console.log(`\nGenerated ${totalGenerated} migration file(s). Run \`ductape db migrate\` to apply them.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/commands/install.ts
CHANGED
|
@@ -1,15 +1,42 @@
|
|
|
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 {
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { HUB_PLATFORM_DIR } from '../lib/config.js';
|
|
5
6
|
import { success } from '../lib/output.js';
|
|
6
7
|
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const TEMPLATES_DIR = path.resolve(__dirname, '../../templates');
|
|
10
|
+
|
|
11
|
+
const HUB_IMAGES = [
|
|
12
|
+
'ductape/platform-api:latest',
|
|
13
|
+
'ductape/platform-workbench:latest',
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
7
16
|
function checkCommand(cmd: string, args: string[]): boolean {
|
|
8
17
|
const r = spawnSync(cmd, args, { stdio: 'ignore' });
|
|
9
18
|
return r.status === 0;
|
|
10
19
|
}
|
|
11
20
|
|
|
12
|
-
|
|
21
|
+
function pull(image: string): Promise<number> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
console.log(`Pulling ${image} …`);
|
|
24
|
+
const child = spawn('docker', ['pull', image], {
|
|
25
|
+
stdio: 'inherit',
|
|
26
|
+
shell: process.platform === 'win32',
|
|
27
|
+
});
|
|
28
|
+
child.on('error', reject);
|
|
29
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function copyTemplate(name: string, dest: string, overwrite = false): void {
|
|
34
|
+
const src = path.join(TEMPLATES_DIR, name);
|
|
35
|
+
if (!overwrite && fs.existsSync(dest)) return;
|
|
36
|
+
fs.copyFileSync(src, dest);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runInstall(): Promise<void> {
|
|
13
40
|
const issues: string[] = [];
|
|
14
41
|
|
|
15
42
|
if (!checkCommand('docker', ['--version'])) {
|
|
@@ -18,31 +45,32 @@ export function runInstall(): void {
|
|
|
18
45
|
issues.push('Docker Compose v2 is required (docker compose)');
|
|
19
46
|
}
|
|
20
47
|
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
let platformDir: string;
|
|
26
|
-
try {
|
|
27
|
-
platformDir = resolvePlatformDir();
|
|
28
|
-
} catch (e) {
|
|
29
|
-
issues.push(e instanceof Error ? e.message : String(e));
|
|
30
|
-
platformDir = '';
|
|
48
|
+
if (issues.length > 0) {
|
|
49
|
+
console.error('Install check failed:\n' + issues.map((i) => ` - ${i}`).join('\n'));
|
|
50
|
+
process.exit(1);
|
|
31
51
|
}
|
|
32
52
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
53
|
+
// Pull platform images from Docker Hub
|
|
54
|
+
for (const image of HUB_IMAGES) {
|
|
55
|
+
const code = await pull(image);
|
|
56
|
+
if (code !== 0) {
|
|
57
|
+
console.error(`Failed to pull ${image} (exit ${code}). Check your internet connection or run \`docker login\`.`);
|
|
58
|
+
process.exit(1);
|
|
39
59
|
}
|
|
40
60
|
}
|
|
41
61
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
// Write platform files to ~/.ductape/platform/
|
|
63
|
+
fs.mkdirSync(HUB_PLATFORM_DIR, { recursive: true });
|
|
64
|
+
|
|
65
|
+
copyTemplate('docker-compose.release.yml', path.join(HUB_PLATFORM_DIR, 'docker-compose.yml'), true);
|
|
66
|
+
copyTemplate('api-gateway.conf', path.join(HUB_PLATFORM_DIR, 'api-gateway.conf'), true);
|
|
67
|
+
copyTemplate('platform.env', path.join(HUB_PLATFORM_DIR, '.env'), false); // don't overwrite existing .env
|
|
46
68
|
|
|
47
|
-
success(
|
|
69
|
+
success(`Platform installed to ${HUB_PLATFORM_DIR}`);
|
|
70
|
+
console.log('');
|
|
71
|
+
console.log(' Edit ~/.ductape/platform/.env to add your AWS credentials (needed for cloud connections).');
|
|
72
|
+
console.log(' Then run: ductape start');
|
|
73
|
+
console.log('');
|
|
74
|
+
console.log(' Workbench: http://localhost:4310');
|
|
75
|
+
console.log(' API: http://localhost:4311');
|
|
48
76
|
}
|