@ductape/cli 0.2.0 → 0.2.2
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/commands/platform.js +1 -1
- 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/platform-api.js +15 -1
- 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/commands/platform.ts +1 -1
- 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/platform-api.ts +15 -1
- 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
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# `ductape db migrate` — Research & Implementation Plan
|
|
2
|
+
|
|
3
|
+
## What already exists
|
|
4
|
+
|
|
5
|
+
### 1. `ductape init` creates `.ductape/`
|
|
6
|
+
[`src/commands/init.ts`](src/commands/init.ts) calls `writeInitArtifacts()` and `saveProjectConfig()`.
|
|
7
|
+
[`src/lib/config.ts`](src/lib/config.ts) line 13 defines:
|
|
8
|
+
```ts
|
|
9
|
+
export const PROJECT_CONFIG_DIR = '.ductape';
|
|
10
|
+
export const PROJECT_CONFIG_PATH = path.join(PROJECT_CONFIG_DIR, 'config.json');
|
|
11
|
+
```
|
|
12
|
+
This is the only change needed in `config.ts` to rename to `ductape/`.
|
|
13
|
+
|
|
14
|
+
### 2. `ductape db <verb>` — proxy passthrough
|
|
15
|
+
[`src/commands/db.ts`](src/commands/db.ts) is a generic proxy that already understands `migration.list` and `migration.run` as method strings (defined in [`src/lib/db-methods.ts`](src/lib/db-methods.ts)). These work today but require a JSON body file (`-f`) and are not user-friendly for the workflow we're building.
|
|
16
|
+
|
|
17
|
+
### 3. SDK `MigrationEngine` — fully built
|
|
18
|
+
[`sdk/ts/src/database/migrations/migration-engine.ts`](../sdk/ts/src/database/migrations/migration-engine.ts):
|
|
19
|
+
- Tracks applied migrations in a `_ductape_migrations` table/collection created automatically on first run.
|
|
20
|
+
- `migrate(migrations[])` — runs all pending (not yet in `_ductape_migrations`) in dependency order.
|
|
21
|
+
- `rollback(count)` / `rollbackAll()` / `reset()`.
|
|
22
|
+
- `getPendingMigrations(migrations[])` — diffs supplied list against `_ductape_migrations`.
|
|
23
|
+
- `getAppliedMigrations()` — returns history with tag, name, checksum, appliedAt.
|
|
24
|
+
- `getStatus(migrations[])` — returns `{ total, completed, pending, pendingMigrations[], appliedMigrations[] }`.
|
|
25
|
+
- Works across PostgreSQL, MySQL, MariaDB, MongoDB, DynamoDB, Cassandra.
|
|
26
|
+
|
|
27
|
+
### 4. SDK `SchemaManager` + `MigrationBuilder`
|
|
28
|
+
[`sdk/ts/src/database/schema/schema-manager.ts`](../sdk/ts/src/database/schema/schema-manager.ts):
|
|
29
|
+
- `createCollection(name, fields[], options?)` — generates an `IMigration` (up: createCollection, down: dropCollection).
|
|
30
|
+
- `addField()`, `dropField()`, `renameField()`, `modifyField()` — each generates a reversible `IMigration`.
|
|
31
|
+
- `createIndex()`, `dropIndex()`, `addConstraint()`, `dropConstraint()`.
|
|
32
|
+
- `MigrationBuilder` — fluent API for batching multiple operations into one migration object.
|
|
33
|
+
- All operations produce `IMigration` objects with `up[]` and `down[]` — the same shape the engine consumes.
|
|
34
|
+
|
|
35
|
+
### 5. `IFieldDefinition` type system
|
|
36
|
+
Supported field types: `string`, `text`, `integer`, `bigint`, `smallint`, `decimal`, `float`, `double`, `uuid`, `boolean`, `date`, `time`, `datetime`, `timestamp`, `binary`, `blob`, `json`, `object`, `array`, `enum`.
|
|
37
|
+
|
|
38
|
+
Field options: `nullable`, `unique`, `primaryKey`, `autoGenerate` (auto-increment/SERIAL), `defaultValue`, `maxLength`, `precision`, `scale`, `enumValues`, `arrayElementType`.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## What does not exist yet
|
|
43
|
+
|
|
44
|
+
### Gap 1 — `ductape/` folder structure (not `.ductape/`)
|
|
45
|
+
Currently `PROJECT_CONFIG_DIR = '.ductape'`. Needs to become `'ductape'`.
|
|
46
|
+
|
|
47
|
+
`ductape init` should also scaffold:
|
|
48
|
+
```
|
|
49
|
+
ductape/
|
|
50
|
+
config.json ← existing (project linking)
|
|
51
|
+
database/
|
|
52
|
+
schema.json ← NEW: declarative schema definition
|
|
53
|
+
migrations/ ← NEW: generated migration files (one JSON per migration)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Gap 2 — `ductape/database/schema.json` format
|
|
57
|
+
No parser, type-mapper, or loader exists. This needs to be written.
|
|
58
|
+
|
|
59
|
+
User-specified format:
|
|
60
|
+
```json
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
"db": "dbTag",
|
|
64
|
+
"tables": {
|
|
65
|
+
"users": {
|
|
66
|
+
"name": { "type": "String", "required": true },
|
|
67
|
+
"email": { "type": "String", "unique": true },
|
|
68
|
+
"age": { "type": "Number" }
|
|
69
|
+
},
|
|
70
|
+
"orders": {
|
|
71
|
+
"userId": { "type": "String", "required": true },
|
|
72
|
+
"total": { "type": "Number" },
|
|
73
|
+
"createdAt": { "type": "Date", "default": "now" }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The CLI must translate this mongoose-style syntax to `IFieldDefinition[]` before it can call `SchemaManager` or `MigrationEngine`. Mapping table needed (see below).
|
|
81
|
+
|
|
82
|
+
### Gap 3 — `ductape db migrate` command
|
|
83
|
+
No high-level migrate command exists. The existing `ductape db migration.run` requires a structured JSON body and is a raw proxy call, not schema-aware.
|
|
84
|
+
|
|
85
|
+
### Gap 4 — Schema diff logic
|
|
86
|
+
The CLI needs to determine which tables and fields in `schema.json` are new (not yet migrated). Two available signals:
|
|
87
|
+
- `migration.list` via proxy — returns `_ductape_migrations` history (knows what ran).
|
|
88
|
+
- `schema.describe` / `schema.list` via proxy — introspects current DB structure directly.
|
|
89
|
+
|
|
90
|
+
The safer approach is to use the migration history (what the CLI itself applied) as the source of truth, and use `schema.describe` only as a secondary check. This keeps the system append-only and prevents false positives from manual DB changes.
|
|
91
|
+
|
|
92
|
+
### Gap 5 — Generated migration files
|
|
93
|
+
When the CLI generates a migration from a schema diff, it should write a timestamped JSON file to `ductape/database/migrations/`:
|
|
94
|
+
```
|
|
95
|
+
ductape/database/migrations/
|
|
96
|
+
20250703_120000_create_users.json
|
|
97
|
+
20250703_120001_create_orders.json
|
|
98
|
+
```
|
|
99
|
+
These files are the local record of what was generated and are committed to version control. On `ductape db migrate`, the CLI reads all files from this folder, diffs against applied migrations, and runs the pending ones.
|
|
100
|
+
|
|
101
|
+
### Gap 6 — Remote schema sync (deferred — see below)
|
|
102
|
+
No mechanism exists to push/pull `schema.json` to/from a remote location. This is the biggest open architectural question.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Mongoose-to-Ductape type mapping
|
|
107
|
+
|
|
108
|
+
| Mongoose style | `FieldType` |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `String` | `string` |
|
|
111
|
+
| `Number` | `integer` (default) or `decimal` if `float: true` |
|
|
112
|
+
| `Boolean` | `boolean` |
|
|
113
|
+
| `Date` | `datetime` |
|
|
114
|
+
| `Buffer` | `binary` |
|
|
115
|
+
| `Mixed` / `Object` | `object` |
|
|
116
|
+
| `Array` | `array` |
|
|
117
|
+
| `ObjectId` / `UUID` | `uuid` |
|
|
118
|
+
| `Map` | `object` |
|
|
119
|
+
| `Decimal128` | `decimal` |
|
|
120
|
+
|
|
121
|
+
Mongoose field options → `IFieldDefinition`:
|
|
122
|
+
| Mongoose | `IFieldDefinition` |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `required: true` | `nullable: false` |
|
|
125
|
+
| `unique: true` | `unique: true` |
|
|
126
|
+
| `default: value` | `defaultValue: value` |
|
|
127
|
+
| `default: 'now'` | `defaultValue: 'CURRENT_TIMESTAMP'` |
|
|
128
|
+
| `maxlength: N` | `maxLength: N` |
|
|
129
|
+
| `enum: [...]` | `type: 'enum', enumValues: [...]` |
|
|
130
|
+
| `index: true` | generates a separate `createIndex` operation |
|
|
131
|
+
| `primaryKey: true` | `primaryKey: true` |
|
|
132
|
+
| `autoGenerate: true` | `autoGenerate: true` |
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Proposed command surface
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
ductape db migrate Run all pending migrations for all dbs in schema.json
|
|
140
|
+
ductape db migrate --env <tag> Run against a specific environment
|
|
141
|
+
ductape db migrate --dry-run Print what would run without applying
|
|
142
|
+
ductape db migrate --db <tag> Limit to one db entry from schema.json
|
|
143
|
+
|
|
144
|
+
ductape db migrate status Show applied vs pending migrations
|
|
145
|
+
ductape db migrate rollback Roll back the last migration
|
|
146
|
+
ductape db migrate rollback -n 3 Roll back the last 3
|
|
147
|
+
|
|
148
|
+
ductape db schema generate Diff schema.json vs applied migrations and write new migration files to migrations/ WITHOUT running them
|
|
149
|
+
ductape db schema push Push local schema.json to remote (Ductape platform storage) — deferred
|
|
150
|
+
ductape db schema pull Fetch remote schema.json to local — deferred
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The core flow for `ductape db migrate`:
|
|
154
|
+
1. Load `ductape/database/schema.json`.
|
|
155
|
+
2. For each `{ db, tables }` entry:
|
|
156
|
+
a. Resolve the db tag to a database_context via `db connect`.
|
|
157
|
+
b. Load all migration files from `ductape/database/migrations/` that match this db.
|
|
158
|
+
c. Call `migration.list` via proxy to get already-applied migration tags.
|
|
159
|
+
d. Filter to pending migrations (files whose tag is not in applied list).
|
|
160
|
+
e. Call `migration.run` via proxy for each pending migration in order.
|
|
161
|
+
3. Report results.
|
|
162
|
+
|
|
163
|
+
The `ductape db schema generate` flow:
|
|
164
|
+
1. Load `schema.json`.
|
|
165
|
+
2. Load existing migration files from `migrations/`.
|
|
166
|
+
3. Reconstruct the "current intended schema" from applied migrations (replay up-ops in memory).
|
|
167
|
+
4. Diff declared schema (from `schema.json`) vs current intended schema.
|
|
168
|
+
5. For new tables → generate `createCollection` migration.
|
|
169
|
+
6. For new fields on existing tables → generate `addField` migration.
|
|
170
|
+
7. For removed fields/tables → refuse by default; require `--destructive` flag.
|
|
171
|
+
8. Write each generated migration as a JSON file in `migrations/`.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Things to watch out for
|
|
176
|
+
|
|
177
|
+
### Non-destructive by default
|
|
178
|
+
`ductape db schema generate` must never emit `dropCollection` or `dropField` operations unless `--destructive` is explicitly passed. Removing a field from `schema.json` silently generating a drop migration could wipe production data.
|
|
179
|
+
|
|
180
|
+
### Schema.json vs migration files as source of truth
|
|
181
|
+
`schema.json` expresses intent. Migration files express history. The CLI generates migration files from `schema.json` changes. Once a migration file exists, it is the immutable record — `schema.json` can diverge (e.g., someone deletes a table definition) but the migration file should not be regenerated or deleted automatically.
|
|
182
|
+
|
|
183
|
+
### db tag resolution
|
|
184
|
+
Each `{ db: 'dbTag' }` in schema.json needs to be connected before migrations can run. The existing `ductape db connect` / `database_context` mechanism handles this. The `migrate` command should do a `connect` call per db entry before running its migrations.
|
|
185
|
+
|
|
186
|
+
### Migration ordering across tables
|
|
187
|
+
When generating migrations for multiple new tables in one run, ordering matters if tables have foreign keys. The user can declare `dependencies` on a migration to enforce order. For the initial scaffold, generate one migration file per table in the order they appear in `schema.json`. A future improvement could auto-detect FK dependencies.
|
|
188
|
+
|
|
189
|
+
### Idempotency
|
|
190
|
+
`migration.run` via the engine already checks `_ductape_migrations` before applying. Re-running `ductape db migrate` is safe — it skips already-applied migrations.
|
|
191
|
+
|
|
192
|
+
### Checksum integrity
|
|
193
|
+
The `MigrationEngine` records a SHA-256 checksum of the migration content at apply time. If a migration file is edited after being applied, the engine will detect a mismatch. The CLI should warn about this rather than silently failing.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## The multi-codebase schema sync problem
|
|
198
|
+
|
|
199
|
+
This is the hardest part of the system and is explicitly deferred.
|
|
200
|
+
|
|
201
|
+
**The problem:** Team member A adds `products` table to `schema.json` and runs `ductape db schema generate`, producing `20250703_create_products.json`. Team member B pulls code and gets the migration file. But if B's `schema.json` is out of date, running `ductape db schema generate` again might produce a duplicate or conflicting migration.
|
|
202
|
+
|
|
203
|
+
**Proposed approach (to validate before implementing):**
|
|
204
|
+
- Migration files (`ductape/database/migrations/*.json`) are committed to version control. This is the primary sync mechanism — same as Prisma, Flyway, Liquibase.
|
|
205
|
+
- `schema.json` is the declarative desired state. It should also be committed, but is the secondary source. Migration files take precedence.
|
|
206
|
+
- `ductape db schema push` uploads `schema.json` to a `_ductape_schema` collection (or Ductape platform storage) so other machines can fetch it.
|
|
207
|
+
- `ductape db schema pull` fetches the remote schema.json and merges it — or simply overwrites the local one (simpler, with a warning).
|
|
208
|
+
- The authoritative migration history is always `_ductape_migrations` in the actual database. The CLI's job is just to generate and apply files; the DB is always the final word on what ran.
|
|
209
|
+
|
|
210
|
+
**Key constraint to solve before implementing sync:** If two developers generate migrations from different `schema.json` states at the same time (e.g., both add different tables locally), their migration files will have different tags and could conflict when both are applied to the same DB. This is the same problem Git branch migrations have in Prisma. The recommended solution is to treat migration file generation as a gated step that only runs on `main` / via CI, not locally in parallel. This is a workflow decision that should be locked in before the sync commands are built.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Implementation order
|
|
215
|
+
|
|
216
|
+
1. Rename `PROJECT_CONFIG_DIR` from `.ductape` to `ductape` in `config.ts`.
|
|
217
|
+
2. Update `writeInitArtifacts()` in `templates.ts` to scaffold `database/schema.json` and `database/migrations/`.
|
|
218
|
+
3. Write `src/lib/schema-loader.ts` — loads and validates `schema.json`, maps mongoose types to `IFieldDefinition`.
|
|
219
|
+
4. Write `src/lib/migration-files.ts` — reads/writes migration JSON files from `ductape/database/migrations/`.
|
|
220
|
+
5. Add `ductape db schema generate` subcommand.
|
|
221
|
+
6. Add `ductape db migrate` subcommand (run pending migration files via proxy).
|
|
222
|
+
7. Add `ductape db migrate status` and `ductape db migrate rollback`.
|
|
223
|
+
8. Deferred: `ductape db schema push` / `ductape db schema pull`.
|
|
@@ -0,0 +1,71 @@
|
|
|
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 { loadSessions, loadNotifications, loadEvents, } from '../lib/apply-loaders.js';
|
|
6
|
+
const MODULE_FOR = {
|
|
7
|
+
sessions: 'sessions',
|
|
8
|
+
notifications: 'notifications',
|
|
9
|
+
events: 'messageBrokers',
|
|
10
|
+
};
|
|
11
|
+
async function syncItems(type, items, productTag, proxy, dryRun) {
|
|
12
|
+
const module = MODULE_FOR[type];
|
|
13
|
+
const listResult = await proxy.execute(module, 'list', buildCrudParams(module, 'list', productTag, {}));
|
|
14
|
+
const existingList = Array.isArray(listResult) ? listResult : [];
|
|
15
|
+
const existingTags = new Set(existingList.map((r) => r.tag ?? '').filter(Boolean));
|
|
16
|
+
for (const item of items) {
|
|
17
|
+
if (!item.tag) {
|
|
18
|
+
console.warn(` [${type}] skipped — missing "tag" field`);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const verb = existingTags.has(item.tag) ? 'update' : 'create';
|
|
22
|
+
if (dryRun) {
|
|
23
|
+
console.log(` [${type}] would ${verb}: ${item.tag}`);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const params = buildCrudParams(module, verb, productTag, { tag: item.tag, body: item });
|
|
28
|
+
await proxy.execute(module, verb, params);
|
|
29
|
+
console.log(` [${type}] ${verb}d: ${item.tag}`);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error(` [${type}] ${verb} failed for "${item.tag}": ${err instanceof Error ? err.message : String(err)}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function runApply(type, opts) {
|
|
37
|
+
const found = findProjectConfig();
|
|
38
|
+
if (!found)
|
|
39
|
+
fail('No linked project. Run `ductape link` from your project directory.');
|
|
40
|
+
const { dir } = found;
|
|
41
|
+
const session = requireSession();
|
|
42
|
+
const proxy = getSdkProxy(session);
|
|
43
|
+
const productTag = session.project.product_tag;
|
|
44
|
+
const dryRun = Boolean(opts.dryRun);
|
|
45
|
+
const targets = type ? [type] : ['sessions', 'notifications', 'events'];
|
|
46
|
+
const loaders = {
|
|
47
|
+
sessions: () => loadSessions(dir),
|
|
48
|
+
notifications: () => loadNotifications(dir),
|
|
49
|
+
events: () => loadEvents(dir),
|
|
50
|
+
};
|
|
51
|
+
for (const t of targets) {
|
|
52
|
+
let items;
|
|
53
|
+
try {
|
|
54
|
+
items = loaders[t]();
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.error(`[${t}] parse error: ${err instanceof Error ? err.message : String(err)}`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (items === null) {
|
|
61
|
+
console.log(`[${t}] no ductape/${t}.json found — skipping`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (items.length === 0) {
|
|
65
|
+
console.log(`[${t}] empty — nothing to apply`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
console.log(`[${t}] applying ${items.length} item(s)${dryRun ? ' (dry run)' : ''}...`);
|
|
69
|
+
await syncItems(t, items, productTag, proxy, dryRun);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface MigrateOpts {
|
|
2
|
+
env?: string;
|
|
3
|
+
db?: string;
|
|
4
|
+
dryRun?: boolean;
|
|
5
|
+
}
|
|
6
|
+
interface MigrateStatusOpts {
|
|
7
|
+
env?: string;
|
|
8
|
+
db?: string;
|
|
9
|
+
json?: boolean;
|
|
10
|
+
}
|
|
11
|
+
interface MigrateRollbackOpts {
|
|
12
|
+
env?: string;
|
|
13
|
+
db?: string;
|
|
14
|
+
n?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function runDbMigrate(opts: MigrateOpts): Promise<void>;
|
|
17
|
+
export declare function runDbMigrateStatus(opts: MigrateStatusOpts): Promise<void>;
|
|
18
|
+
export declare function runDbMigrateRollback(opts: MigrateRollbackOpts): Promise<void>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
function buildDbContext(dbTag, envSlug, productTag) {
|
|
7
|
+
return { database: dbTag, env: envSlug, product: productTag };
|
|
8
|
+
}
|
|
9
|
+
async function getAppliedTags(proxy, dbContext, productTag, envSlug) {
|
|
10
|
+
try {
|
|
11
|
+
const result = await proxy.execute('migration.list', [{ product: productTag, env: envSlug, database: dbContext.database }], dbContext);
|
|
12
|
+
const list = Array.isArray(result)
|
|
13
|
+
? result
|
|
14
|
+
: Array.isArray(result.data)
|
|
15
|
+
? result.data
|
|
16
|
+
: [];
|
|
17
|
+
return list.map((r) => r.tag ?? '').filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function runDbMigrate(opts) {
|
|
24
|
+
const found = findProjectConfig();
|
|
25
|
+
if (!found)
|
|
26
|
+
fail('No linked project. Run `ductape link` from your project directory.');
|
|
27
|
+
const { dir } = found;
|
|
28
|
+
const session = requireSession();
|
|
29
|
+
const proxy = getDbProxy(session);
|
|
30
|
+
const envSlug = opts.env ?? session.project.env_slug;
|
|
31
|
+
const productTag = session.project.product_tag;
|
|
32
|
+
const schemaEntries = loadSchemaFile(dir);
|
|
33
|
+
const targets = opts.db ? schemaEntries.filter((e) => e.db === opts.db) : schemaEntries;
|
|
34
|
+
if (targets.length === 0) {
|
|
35
|
+
fail(opts.db ? `No db entry found for tag "${opts.db}".` : 'schema.json is empty.');
|
|
36
|
+
}
|
|
37
|
+
let totalApplied = 0;
|
|
38
|
+
let totalDry = 0;
|
|
39
|
+
for (const entry of targets) {
|
|
40
|
+
const { db } = entry;
|
|
41
|
+
const dbContext = buildDbContext(db, envSlug, productTag);
|
|
42
|
+
const migrations = loadMigrationFiles(dir, db);
|
|
43
|
+
if (migrations.length === 0) {
|
|
44
|
+
console.log(`[${db}] No migration files found. Run \`ductape db schema generate\` first.`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const appliedTags = await getAppliedTags(proxy, dbContext, productTag, envSlug);
|
|
48
|
+
const pending = migrations.filter((m) => !appliedTags.includes(m.tag));
|
|
49
|
+
if (pending.length === 0) {
|
|
50
|
+
console.log(`[${db}] Already up to date.`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
console.log(`[${db}] ${pending.length} pending migration(s).`);
|
|
54
|
+
for (const migration of pending) {
|
|
55
|
+
if (opts.dryRun) {
|
|
56
|
+
console.log(` [dry-run] Would apply: ${migration.tag}`);
|
|
57
|
+
totalDry++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
process.stdout.write(` Applying ${migration.tag}... `);
|
|
61
|
+
try {
|
|
62
|
+
await proxy.execute('migration.run', [{ product: productTag, env: envSlug, database: db, migrations: [migration] }], dbContext);
|
|
63
|
+
console.log('done');
|
|
64
|
+
totalApplied++;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.log('failed');
|
|
68
|
+
fail(`Migration "${migration.tag}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (opts.dryRun) {
|
|
73
|
+
console.log(`\nDry run: ${totalDry} migration(s) would be applied.`);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
console.log(`\nApplied ${totalApplied} migration(s).`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function runDbMigrateStatus(opts) {
|
|
80
|
+
const found = findProjectConfig();
|
|
81
|
+
if (!found)
|
|
82
|
+
fail('No linked project. Run `ductape link` from your project directory.');
|
|
83
|
+
const { dir } = found;
|
|
84
|
+
const session = requireSession();
|
|
85
|
+
const proxy = getDbProxy(session);
|
|
86
|
+
const envSlug = opts.env ?? session.project.env_slug;
|
|
87
|
+
const productTag = session.project.product_tag;
|
|
88
|
+
const schemaEntries = loadSchemaFile(dir);
|
|
89
|
+
const targets = opts.db ? schemaEntries.filter((e) => e.db === opts.db) : schemaEntries;
|
|
90
|
+
const statusReport = {};
|
|
91
|
+
for (const entry of targets) {
|
|
92
|
+
const { db } = entry;
|
|
93
|
+
const dbContext = buildDbContext(db, envSlug, productTag);
|
|
94
|
+
const migrations = loadMigrationFiles(dir, db);
|
|
95
|
+
const appliedTags = await getAppliedTags(proxy, dbContext, productTag, envSlug);
|
|
96
|
+
const applied = migrations.filter((m) => appliedTags.includes(m.tag)).map((m) => m.tag);
|
|
97
|
+
const pending = migrations.filter((m) => !appliedTags.includes(m.tag)).map((m) => m.tag);
|
|
98
|
+
statusReport[db] = { applied, pending };
|
|
99
|
+
if (!opts.json) {
|
|
100
|
+
console.log(`\n[${db}]`);
|
|
101
|
+
if (applied.length > 0) {
|
|
102
|
+
console.log(` Applied (${applied.length}):`);
|
|
103
|
+
for (const t of applied)
|
|
104
|
+
console.log(` + ${t}`);
|
|
105
|
+
}
|
|
106
|
+
if (pending.length > 0) {
|
|
107
|
+
console.log(` Pending (${pending.length}):`);
|
|
108
|
+
for (const t of pending)
|
|
109
|
+
console.log(` - ${t}`);
|
|
110
|
+
}
|
|
111
|
+
if (applied.length === 0 && pending.length === 0) {
|
|
112
|
+
console.log(' No migration files found.');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (opts.json)
|
|
117
|
+
printJson(statusReport, true);
|
|
118
|
+
}
|
|
119
|
+
export async function runDbMigrateRollback(opts) {
|
|
120
|
+
const found = findProjectConfig();
|
|
121
|
+
if (!found)
|
|
122
|
+
fail('No linked project. Run `ductape link` from your project directory.');
|
|
123
|
+
const { dir } = found;
|
|
124
|
+
const session = requireSession();
|
|
125
|
+
const proxy = getDbProxy(session);
|
|
126
|
+
const envSlug = opts.env ?? session.project.env_slug;
|
|
127
|
+
const productTag = session.project.product_tag;
|
|
128
|
+
const rollbackCount = opts.n ?? 1;
|
|
129
|
+
const schemaEntries = loadSchemaFile(dir);
|
|
130
|
+
const targets = opts.db ? schemaEntries.filter((e) => e.db === opts.db) : schemaEntries;
|
|
131
|
+
for (const entry of targets) {
|
|
132
|
+
const { db } = entry;
|
|
133
|
+
const dbContext = buildDbContext(db, envSlug, productTag);
|
|
134
|
+
const migrations = loadMigrationFiles(dir, db);
|
|
135
|
+
const appliedTags = await getAppliedTags(proxy, dbContext, productTag, envSlug);
|
|
136
|
+
const applied = migrations.filter((m) => appliedTags.includes(m.tag));
|
|
137
|
+
const toRollback = applied.slice(-rollbackCount).reverse();
|
|
138
|
+
if (toRollback.length === 0) {
|
|
139
|
+
console.log(`[${db}] Nothing to roll back.`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
for (const migration of toRollback) {
|
|
143
|
+
process.stdout.write(` Rolling back ${migration.tag}... `);
|
|
144
|
+
try {
|
|
145
|
+
await proxy.execute('migration.rollback', [{ product: productTag, env: envSlug, database: db, migrations: [migration] }], dbContext);
|
|
146
|
+
console.log('done');
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
console.log('failed');
|
|
150
|
+
fail(`Rollback of "${migration.tag}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
function replayMigrations(migrations) {
|
|
7
|
+
const schema = new Map();
|
|
8
|
+
for (const migration of migrations) {
|
|
9
|
+
for (const op of migration.up) {
|
|
10
|
+
if (op.type === 'createCollection') {
|
|
11
|
+
if (!schema.has(op.name)) {
|
|
12
|
+
schema.set(op.name, { fields: new Set(op.fields.map((f) => f.name)) });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
else if (op.type === 'dropCollection') {
|
|
16
|
+
schema.delete(op.name);
|
|
17
|
+
}
|
|
18
|
+
else if (op.type === 'addField') {
|
|
19
|
+
const t = schema.get(op.collection);
|
|
20
|
+
if (t)
|
|
21
|
+
t.fields.add(op.field.name);
|
|
22
|
+
}
|
|
23
|
+
else if (op.type === 'dropField') {
|
|
24
|
+
const t = schema.get(op.collection);
|
|
25
|
+
if (t)
|
|
26
|
+
t.fields.delete(op.fieldName);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return schema;
|
|
31
|
+
}
|
|
32
|
+
export async function runDbSchemaGenerate(opts) {
|
|
33
|
+
const found = findProjectConfig();
|
|
34
|
+
if (!found)
|
|
35
|
+
fail('No linked project. Run `ductape link` from your project directory.');
|
|
36
|
+
const { dir } = found;
|
|
37
|
+
const schemaEntries = loadSchemaFile(dir);
|
|
38
|
+
const targets = opts.db
|
|
39
|
+
? schemaEntries.filter((e) => e.db === opts.db)
|
|
40
|
+
: schemaEntries;
|
|
41
|
+
if (targets.length === 0) {
|
|
42
|
+
fail(opts.db ? `No db entry found for tag "${opts.db}".` : 'schema.json is empty.');
|
|
43
|
+
}
|
|
44
|
+
let totalGenerated = 0;
|
|
45
|
+
for (const entry of targets) {
|
|
46
|
+
const { db, tables } = entry;
|
|
47
|
+
const existing = loadMigrationFiles(dir, db);
|
|
48
|
+
const current = replayMigrations(existing);
|
|
49
|
+
const newMigrations = [];
|
|
50
|
+
let counter = existing.length + 1;
|
|
51
|
+
for (const [tableName, fieldDefs] of Object.entries(tables)) {
|
|
52
|
+
const parsed = parseTableDef(tableName, fieldDefs);
|
|
53
|
+
const currentTable = current.get(tableName);
|
|
54
|
+
if (!currentTable) {
|
|
55
|
+
const up = [
|
|
56
|
+
{
|
|
57
|
+
type: 'createCollection',
|
|
58
|
+
name: tableName,
|
|
59
|
+
fields: parsed.fields,
|
|
60
|
+
ifNotExists: true,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
for (const { fieldName, unique } of parsed.indexFields) {
|
|
64
|
+
up.push({
|
|
65
|
+
type: 'createIndex',
|
|
66
|
+
collection: tableName,
|
|
67
|
+
name: `${tableName}_${fieldName}_idx`,
|
|
68
|
+
fields: [{ name: fieldName }],
|
|
69
|
+
unique,
|
|
70
|
+
ifNotExists: true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const down = [
|
|
74
|
+
{ type: 'dropCollection', name: tableName, ifExists: true },
|
|
75
|
+
];
|
|
76
|
+
newMigrations.push({
|
|
77
|
+
tag: `create_${tableName}`,
|
|
78
|
+
name: `Create collection ${tableName}`,
|
|
79
|
+
up,
|
|
80
|
+
down,
|
|
81
|
+
createdAt: new Date().toISOString(),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const addOps = [];
|
|
86
|
+
const dropOps = [];
|
|
87
|
+
for (const field of parsed.fields) {
|
|
88
|
+
if (!currentTable.fields.has(field.name)) {
|
|
89
|
+
addOps.push({ type: 'addField', collection: tableName, field });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const desiredFields = new Set(parsed.fields.map((f) => f.name));
|
|
93
|
+
const removedFields = [];
|
|
94
|
+
for (const existingField of currentTable.fields) {
|
|
95
|
+
if (!desiredFields.has(existingField)) {
|
|
96
|
+
removedFields.push(existingField);
|
|
97
|
+
if (opts.destructive) {
|
|
98
|
+
dropOps.push({ type: 'dropField', collection: tableName, fieldName: existingField });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!opts.destructive && removedFields.length > 0) {
|
|
103
|
+
console.warn(` [${db}] Warning: field(s) removed from schema.json for "${tableName}": ${removedFields.join(', ')}. Pass --destructive to generate drop migrations.`);
|
|
104
|
+
}
|
|
105
|
+
const allOps = [...addOps, ...dropOps];
|
|
106
|
+
if (allOps.length > 0) {
|
|
107
|
+
const down = addOps.map((op) => ({
|
|
108
|
+
type: 'dropField',
|
|
109
|
+
collection: tableName,
|
|
110
|
+
fieldName: op.field.name,
|
|
111
|
+
}));
|
|
112
|
+
newMigrations.push({
|
|
113
|
+
tag: `alter_${tableName}_${Date.now()}`,
|
|
114
|
+
name: `Alter collection ${tableName}`,
|
|
115
|
+
up: allOps,
|
|
116
|
+
down,
|
|
117
|
+
createdAt: new Date().toISOString(),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (newMigrations.length === 0) {
|
|
123
|
+
console.log(`[${db}] Schema is up to date. No migrations generated.`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
for (const migration of newMigrations) {
|
|
127
|
+
const filePath = writeMigrationFile(dir, db, migration, counter++);
|
|
128
|
+
console.log(`[${db}] Generated ${path.basename(filePath)}`);
|
|
129
|
+
totalGenerated++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (totalGenerated > 0) {
|
|
133
|
+
console.log(`\nGenerated ${totalGenerated} migration file(s). Run \`ductape db migrate\` to apply them.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function runInstall(): void
|
|
1
|
+
export declare function runInstall(): Promise<void>;
|