@ductape/cli 0.2.5 → 0.2.6
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/dist/commands/db-schema.js +207 -2
- package/package.json +1 -1
- package/src/commands/db-schema.ts +176 -2
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { findProjectConfig } from '../lib/config.js';
|
|
3
|
-
import { loadSchemaFile, parseTableDef } from '../lib/schema-loader.js';
|
|
4
|
+
import { loadSchemaFile, getSchemaPath, parseTableDef } from '../lib/schema-loader.js';
|
|
4
5
|
import { loadMigrationFiles, writeMigrationFile } from '../lib/migration-files.js';
|
|
5
6
|
import { fail } from '../lib/output.js';
|
|
7
|
+
import { getDbProxy, requireSession } from '../lib/proxy/context.js';
|
|
6
8
|
function replayMigrations(migrations) {
|
|
7
9
|
const schema = new Map();
|
|
8
10
|
for (const migration of migrations) {
|
|
@@ -29,16 +31,211 @@ function replayMigrations(migrations) {
|
|
|
29
31
|
}
|
|
30
32
|
return schema;
|
|
31
33
|
}
|
|
34
|
+
function columnTypeToMongooseType(raw) {
|
|
35
|
+
// Normalise: uppercase, collapse whitespace (handles "character varying", "timestamp without time zone", etc.)
|
|
36
|
+
const t = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
37
|
+
switch (t) {
|
|
38
|
+
case 'BIGINT':
|
|
39
|
+
case 'INT8': return 'bigint';
|
|
40
|
+
case 'SMALLINT':
|
|
41
|
+
case 'TINYINT':
|
|
42
|
+
case 'INT2': return 'smallint';
|
|
43
|
+
case 'INTEGER':
|
|
44
|
+
case 'INT':
|
|
45
|
+
case 'INT4':
|
|
46
|
+
case 'NUMBER': return 'number';
|
|
47
|
+
case 'FLOAT':
|
|
48
|
+
case 'REAL':
|
|
49
|
+
case 'FLOAT4':
|
|
50
|
+
case 'FLOAT8':
|
|
51
|
+
case 'DOUBLE PRECISION': return 'number'; // caller sets float:true
|
|
52
|
+
case 'DOUBLE': return 'double';
|
|
53
|
+
case 'DECIMAL':
|
|
54
|
+
case 'NUMERIC':
|
|
55
|
+
case 'DECIMAL128': return 'decimal';
|
|
56
|
+
case 'TEXT':
|
|
57
|
+
case 'MEDIUMTEXT':
|
|
58
|
+
case 'LONGTEXT':
|
|
59
|
+
case 'TINYTEXT': return 'text';
|
|
60
|
+
case 'STRING':
|
|
61
|
+
case 'VARCHAR':
|
|
62
|
+
case 'CHARACTER VARYING':
|
|
63
|
+
case 'CHAR':
|
|
64
|
+
case 'CHARACTER':
|
|
65
|
+
case 'BPCHAR': return 'string';
|
|
66
|
+
case 'BOOLEAN':
|
|
67
|
+
case 'BOOL': return 'boolean';
|
|
68
|
+
case 'DATE':
|
|
69
|
+
case 'DATETIME': return 'date';
|
|
70
|
+
case 'TIME':
|
|
71
|
+
case 'TIME WITHOUT TIME ZONE':
|
|
72
|
+
case 'TIME WITH TIME ZONE': return 'time';
|
|
73
|
+
case 'TIMESTAMP':
|
|
74
|
+
case 'TIMESTAMPTZ':
|
|
75
|
+
case 'TIMESTAMP WITHOUT TIME ZONE':
|
|
76
|
+
case 'TIMESTAMP WITH TIME ZONE': return 'timestamp';
|
|
77
|
+
case 'JSON':
|
|
78
|
+
case 'JSONB': return 'json';
|
|
79
|
+
case 'BINARY':
|
|
80
|
+
case 'VARBINARY':
|
|
81
|
+
case 'BYTEA': return 'binary';
|
|
82
|
+
case 'BLOB':
|
|
83
|
+
case 'MEDIUMBLOB':
|
|
84
|
+
case 'LONGBLOB':
|
|
85
|
+
case 'TINYBLOB': return 'blob';
|
|
86
|
+
case 'UUID':
|
|
87
|
+
case 'OBJECTID': return 'uuid';
|
|
88
|
+
case 'OBJECT':
|
|
89
|
+
case 'MIXED':
|
|
90
|
+
case 'MAP':
|
|
91
|
+
case 'HSTORE': return 'mixed';
|
|
92
|
+
case 'ARRAY': return 'array';
|
|
93
|
+
default: return 'string';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function buildMongooseFieldDef(col) {
|
|
97
|
+
const rawType = String(col.type ?? 'string');
|
|
98
|
+
const mongooseType = columnTypeToMongooseType(rawType);
|
|
99
|
+
const def = { type: mongooseType };
|
|
100
|
+
const upperType = rawType.toUpperCase().trim();
|
|
101
|
+
if (upperType === 'FLOAT' || upperType === 'REAL' || upperType === 'FLOAT4' || upperType === 'FLOAT8' || upperType === 'DOUBLE PRECISION')
|
|
102
|
+
def.float = true;
|
|
103
|
+
// Handle both IColumnDefinition style (primaryKey/unique) and IColumnInfo style (isPrimaryKey/isUnique)
|
|
104
|
+
if (col.nullable === false || col.required === true)
|
|
105
|
+
def.required = true;
|
|
106
|
+
if (col.unique === true || col.isUnique === true)
|
|
107
|
+
def.unique = true;
|
|
108
|
+
if (col.primaryKey === true || col.isPrimaryKey === true)
|
|
109
|
+
def.primaryKey = true;
|
|
110
|
+
if (col.autoIncrement === true || col.isAutoIncrement === true || col.autoGenerate === true)
|
|
111
|
+
def.autoGenerate = true;
|
|
112
|
+
// Handle both camelCase (defaultValue) and snake_case (default_value) from different adapters
|
|
113
|
+
const defVal = col.defaultValue ?? col.default_value;
|
|
114
|
+
if (defVal !== undefined && defVal !== null)
|
|
115
|
+
def.default = defVal;
|
|
116
|
+
// Handle both camelCase (length/maxLength) and snake_case (max_length) from different adapters
|
|
117
|
+
const len = col.length ?? col.maxLength ?? col.max_length;
|
|
118
|
+
if (typeof len === 'number')
|
|
119
|
+
def.maxlength = len;
|
|
120
|
+
if (Array.isArray(col.enumValues) && col.enumValues.length > 0) {
|
|
121
|
+
def.type = 'string';
|
|
122
|
+
def.enum = col.enumValues;
|
|
123
|
+
}
|
|
124
|
+
return def;
|
|
125
|
+
}
|
|
126
|
+
async function tryHydrateFromCloud(dir, schemaPath, opts) {
|
|
127
|
+
if (!opts.db)
|
|
128
|
+
return [];
|
|
129
|
+
let session;
|
|
130
|
+
try {
|
|
131
|
+
session = requireSession();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const proxy = getDbProxy(session);
|
|
137
|
+
const envSlug = session.project.env_slug;
|
|
138
|
+
const productTag = session.project.product_tag;
|
|
139
|
+
const dbContext = { database: opts.db, env: envSlug, product: productTag };
|
|
140
|
+
let tableNames = [];
|
|
141
|
+
try {
|
|
142
|
+
const raw = await proxy.execute('schema.list', [null], dbContext);
|
|
143
|
+
if (Array.isArray(raw)) {
|
|
144
|
+
tableNames = raw.map((r) => typeof r === 'string' ? r : typeof r.name === 'string' ? String(r.name) : null).filter((n) => n !== null);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
if (tableNames.length === 0)
|
|
151
|
+
return [];
|
|
152
|
+
const tables = {};
|
|
153
|
+
for (const tableName of tableNames) {
|
|
154
|
+
try {
|
|
155
|
+
const tableSchema = await proxy.execute('schema.describe', [tableName], dbContext);
|
|
156
|
+
const cols = Array.isArray(tableSchema?.columns)
|
|
157
|
+
? tableSchema.columns
|
|
158
|
+
: Array.isArray(tableSchema)
|
|
159
|
+
? tableSchema
|
|
160
|
+
: [];
|
|
161
|
+
if (cols.length === 0) {
|
|
162
|
+
tables[tableName] = {};
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const indexedFields = new Set();
|
|
166
|
+
const uniqueIndexFields = new Set();
|
|
167
|
+
const rawIndexes = tableSchema?.indexes;
|
|
168
|
+
if (Array.isArray(rawIndexes)) {
|
|
169
|
+
for (const idx of rawIndexes) {
|
|
170
|
+
const idxCols = Array.isArray(idx.columns) ? idx.columns : [];
|
|
171
|
+
if (idxCols.length === 1) {
|
|
172
|
+
const fieldName = String(idxCols[0].name ?? idxCols[0]);
|
|
173
|
+
if (idx.unique)
|
|
174
|
+
uniqueIndexFields.add(fieldName);
|
|
175
|
+
else
|
|
176
|
+
indexedFields.add(fieldName);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const fieldMap = {};
|
|
181
|
+
for (const col of cols) {
|
|
182
|
+
const name = String(col.name ?? '');
|
|
183
|
+
if (!name)
|
|
184
|
+
continue;
|
|
185
|
+
const def = buildMongooseFieldDef(col);
|
|
186
|
+
if (uniqueIndexFields.has(name)) {
|
|
187
|
+
def.unique = true;
|
|
188
|
+
def.index = true;
|
|
189
|
+
}
|
|
190
|
+
else if (indexedFields.has(name))
|
|
191
|
+
def.index = true;
|
|
192
|
+
fieldMap[name] = def;
|
|
193
|
+
}
|
|
194
|
+
tables[tableName] = fieldMap;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
tables[tableName] = {};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const entry = { db: opts.db, tables };
|
|
201
|
+
const entries = [entry];
|
|
202
|
+
fs.writeFileSync(schemaPath, JSON.stringify(entries, null, 2), 'utf8');
|
|
203
|
+
console.log(`[${opts.db}] Hydrated schema.json from cloud (${tableNames.length} collection(s)): ${tableNames.join(', ')}`);
|
|
204
|
+
return entries;
|
|
205
|
+
}
|
|
32
206
|
export async function runDbSchemaGenerate(opts) {
|
|
33
207
|
const found = findProjectConfig();
|
|
34
208
|
if (!found)
|
|
35
209
|
fail('No linked project. Run `ductape link` from your project directory.');
|
|
36
210
|
const { dir } = found;
|
|
37
|
-
const
|
|
211
|
+
const schemaPath = getSchemaPath(dir);
|
|
212
|
+
let schemaEntries;
|
|
213
|
+
let wasScaffolded = false;
|
|
214
|
+
if (!fs.existsSync(schemaPath)) {
|
|
215
|
+
// Create directory and empty schema.json first
|
|
216
|
+
fs.mkdirSync(path.dirname(schemaPath), { recursive: true });
|
|
217
|
+
fs.writeFileSync(schemaPath, '[]', 'utf8');
|
|
218
|
+
wasScaffolded = true;
|
|
219
|
+
console.log(`Created ${path.relative(dir, schemaPath)}`);
|
|
220
|
+
// Try to hydrate from the cloud (requires --db and an active session)
|
|
221
|
+
schemaEntries = await tryHydrateFromCloud(dir, schemaPath, opts);
|
|
222
|
+
if (schemaEntries.length === 0) {
|
|
223
|
+
const hint = opts.db
|
|
224
|
+
? `(no existing collections found in "${opts.db}" for env "${requireSilent()?.project?.env_slug ?? 'default'}")`
|
|
225
|
+
: '(pass --db <tag> to auto-import existing collections from the cloud)';
|
|
226
|
+
console.log(`schema.json is empty ${hint}. Add your collection definitions and run \`ductape db schema generate\` again.`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
schemaEntries = loadSchemaFile(dir);
|
|
232
|
+
}
|
|
38
233
|
const targets = opts.db
|
|
39
234
|
? schemaEntries.filter((e) => e.db === opts.db)
|
|
40
235
|
: schemaEntries;
|
|
41
236
|
if (targets.length === 0) {
|
|
237
|
+
if (wasScaffolded)
|
|
238
|
+
return; // already printed a message above
|
|
42
239
|
fail(opts.db ? `No db entry found for tag "${opts.db}".` : 'schema.json is empty.');
|
|
43
240
|
}
|
|
44
241
|
let totalGenerated = 0;
|
|
@@ -133,3 +330,11 @@ export async function runDbSchemaGenerate(opts) {
|
|
|
133
330
|
console.log(`\nGenerated ${totalGenerated} migration file(s). Run \`ductape db migrate\` to apply them.`);
|
|
134
331
|
}
|
|
135
332
|
}
|
|
333
|
+
function requireSilent() {
|
|
334
|
+
try {
|
|
335
|
+
return requireSession();
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { findProjectConfig } from '../lib/config.js';
|
|
3
|
-
import { loadSchemaFile, parseTableDef } from '../lib/schema-loader.js';
|
|
4
|
+
import { loadSchemaFile, getSchemaPath, parseTableDef } from '../lib/schema-loader.js';
|
|
4
5
|
import { loadMigrationFiles, writeMigrationFile } from '../lib/migration-files.js';
|
|
5
6
|
import { fail } from '../lib/output.js';
|
|
7
|
+
import { getDbProxy, requireSession } from '../lib/proxy/context.js';
|
|
8
|
+
import type { SchemaEntry, MongooseFieldDef } from '../lib/schema-loader.js';
|
|
6
9
|
import type {
|
|
7
10
|
IMigration,
|
|
8
11
|
IMigrationOperation,
|
|
@@ -43,17 +46,184 @@ function replayMigrations(migrations: IMigration[]): Map<string, ReconstructedTa
|
|
|
43
46
|
return schema;
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
function columnTypeToMongooseType(raw: string): string {
|
|
50
|
+
// Normalise: uppercase, collapse whitespace (handles "character varying", "timestamp without time zone", etc.)
|
|
51
|
+
const t = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
52
|
+
switch (t) {
|
|
53
|
+
case 'BIGINT': case 'INT8': return 'bigint';
|
|
54
|
+
case 'SMALLINT': case 'TINYINT': case 'INT2': return 'smallint';
|
|
55
|
+
case 'INTEGER': case 'INT': case 'INT4': case 'NUMBER': return 'number';
|
|
56
|
+
case 'FLOAT': case 'REAL': case 'FLOAT4': case 'FLOAT8': case 'DOUBLE PRECISION': return 'number'; // caller sets float:true
|
|
57
|
+
case 'DOUBLE': return 'double';
|
|
58
|
+
case 'DECIMAL': case 'NUMERIC': case 'DECIMAL128': return 'decimal';
|
|
59
|
+
case 'TEXT': case 'MEDIUMTEXT': case 'LONGTEXT': case 'TINYTEXT': return 'text';
|
|
60
|
+
case 'STRING': case 'VARCHAR': case 'CHARACTER VARYING': case 'CHAR': case 'CHARACTER': case 'BPCHAR': return 'string';
|
|
61
|
+
case 'BOOLEAN': case 'BOOL': return 'boolean';
|
|
62
|
+
case 'DATE': case 'DATETIME': return 'date';
|
|
63
|
+
case 'TIME': case 'TIME WITHOUT TIME ZONE': case 'TIME WITH TIME ZONE': return 'time';
|
|
64
|
+
case 'TIMESTAMP': case 'TIMESTAMPTZ': case 'TIMESTAMP WITHOUT TIME ZONE': case 'TIMESTAMP WITH TIME ZONE': return 'timestamp';
|
|
65
|
+
case 'JSON': case 'JSONB': return 'json';
|
|
66
|
+
case 'BINARY': case 'VARBINARY': case 'BYTEA': return 'binary';
|
|
67
|
+
case 'BLOB': case 'MEDIUMBLOB': case 'LONGBLOB': case 'TINYBLOB': return 'blob';
|
|
68
|
+
case 'UUID': case 'OBJECTID': return 'uuid';
|
|
69
|
+
case 'OBJECT': case 'MIXED': case 'MAP': case 'HSTORE': return 'mixed';
|
|
70
|
+
case 'ARRAY': return 'array';
|
|
71
|
+
default: return 'string';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildMongooseFieldDef(col: Record<string, unknown>): MongooseFieldDef {
|
|
76
|
+
const rawType = String(col.type ?? 'string');
|
|
77
|
+
const mongooseType = columnTypeToMongooseType(rawType);
|
|
78
|
+
const def: MongooseFieldDef = { type: mongooseType };
|
|
79
|
+
|
|
80
|
+
const upperType = rawType.toUpperCase().trim();
|
|
81
|
+
if (upperType === 'FLOAT' || upperType === 'REAL' || upperType === 'FLOAT4' || upperType === 'FLOAT8' || upperType === 'DOUBLE PRECISION') def.float = true;
|
|
82
|
+
|
|
83
|
+
// Handle both IColumnDefinition style (primaryKey/unique) and IColumnInfo style (isPrimaryKey/isUnique)
|
|
84
|
+
if (col.nullable === false || col.required === true) def.required = true;
|
|
85
|
+
if (col.unique === true || col.isUnique === true) def.unique = true;
|
|
86
|
+
if (col.primaryKey === true || col.isPrimaryKey === true) def.primaryKey = true;
|
|
87
|
+
if (col.autoIncrement === true || col.isAutoIncrement === true || col.autoGenerate === true) def.autoGenerate = true;
|
|
88
|
+
|
|
89
|
+
// Handle both camelCase (defaultValue) and snake_case (default_value) from different adapters
|
|
90
|
+
const defVal = col.defaultValue ?? col.default_value;
|
|
91
|
+
if (defVal !== undefined && defVal !== null) def.default = defVal as unknown;
|
|
92
|
+
|
|
93
|
+
// Handle both camelCase (length/maxLength) and snake_case (max_length) from different adapters
|
|
94
|
+
const len = col.length ?? col.maxLength ?? col.max_length;
|
|
95
|
+
if (typeof len === 'number') def.maxlength = len;
|
|
96
|
+
|
|
97
|
+
if (Array.isArray(col.enumValues) && (col.enumValues as string[]).length > 0) {
|
|
98
|
+
def.type = 'string';
|
|
99
|
+
def.enum = col.enumValues as string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return def;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function tryHydrateFromCloud(
|
|
106
|
+
dir: string,
|
|
107
|
+
schemaPath: string,
|
|
108
|
+
opts: SchemaGenerateOpts,
|
|
109
|
+
): Promise<SchemaEntry[]> {
|
|
110
|
+
if (!opts.db) return [];
|
|
111
|
+
|
|
112
|
+
let session: ReturnType<typeof requireSession>;
|
|
113
|
+
try {
|
|
114
|
+
session = requireSession();
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const proxy = getDbProxy(session);
|
|
120
|
+
const envSlug = session.project.env_slug;
|
|
121
|
+
const productTag = session.project.product_tag;
|
|
122
|
+
const dbContext = { database: opts.db, env: envSlug, product: productTag };
|
|
123
|
+
|
|
124
|
+
let tableNames: string[] = [];
|
|
125
|
+
try {
|
|
126
|
+
const raw = await proxy.execute<unknown>('schema.list', [null], dbContext);
|
|
127
|
+
if (Array.isArray(raw)) {
|
|
128
|
+
tableNames = (raw as unknown[]).map((r) =>
|
|
129
|
+
typeof r === 'string' ? r : typeof (r as Record<string, unknown>).name === 'string' ? String((r as Record<string, unknown>).name) : null,
|
|
130
|
+
).filter((n): n is string => n !== null);
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (tableNames.length === 0) return [];
|
|
137
|
+
|
|
138
|
+
const tables: Record<string, Record<string, MongooseFieldDef>> = {};
|
|
139
|
+
|
|
140
|
+
for (const tableName of tableNames) {
|
|
141
|
+
try {
|
|
142
|
+
const tableSchema = await proxy.execute<unknown>('schema.describe', [tableName], dbContext);
|
|
143
|
+
const cols: unknown[] = Array.isArray((tableSchema as Record<string, unknown>)?.columns)
|
|
144
|
+
? ((tableSchema as Record<string, unknown>).columns as unknown[])
|
|
145
|
+
: Array.isArray(tableSchema)
|
|
146
|
+
? (tableSchema as unknown[])
|
|
147
|
+
: [];
|
|
148
|
+
|
|
149
|
+
if (cols.length === 0) {
|
|
150
|
+
tables[tableName] = {};
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const indexedFields = new Set<string>();
|
|
155
|
+
const uniqueIndexFields = new Set<string>();
|
|
156
|
+
const rawIndexes = (tableSchema as Record<string, unknown>)?.indexes;
|
|
157
|
+
if (Array.isArray(rawIndexes)) {
|
|
158
|
+
for (const idx of rawIndexes as Record<string, unknown>[]) {
|
|
159
|
+
const idxCols = Array.isArray(idx.columns) ? (idx.columns as Record<string, unknown>[]) : [];
|
|
160
|
+
if (idxCols.length === 1) {
|
|
161
|
+
const fieldName = String(idxCols[0].name ?? idxCols[0]);
|
|
162
|
+
if (idx.unique) uniqueIndexFields.add(fieldName);
|
|
163
|
+
else indexedFields.add(fieldName);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const fieldMap: Record<string, MongooseFieldDef> = {};
|
|
169
|
+
for (const col of cols as Record<string, unknown>[]) {
|
|
170
|
+
const name = String(col.name ?? '');
|
|
171
|
+
if (!name) continue;
|
|
172
|
+
const def = buildMongooseFieldDef(col);
|
|
173
|
+
if (uniqueIndexFields.has(name)) { def.unique = true; def.index = true; }
|
|
174
|
+
else if (indexedFields.has(name)) def.index = true;
|
|
175
|
+
fieldMap[name] = def;
|
|
176
|
+
}
|
|
177
|
+
tables[tableName] = fieldMap;
|
|
178
|
+
} catch {
|
|
179
|
+
tables[tableName] = {};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const entry: SchemaEntry = { db: opts.db, tables };
|
|
184
|
+
const entries = [entry];
|
|
185
|
+
fs.writeFileSync(schemaPath, JSON.stringify(entries, null, 2), 'utf8');
|
|
186
|
+
console.log(`[${opts.db}] Hydrated schema.json from cloud (${tableNames.length} collection(s)): ${tableNames.join(', ')}`);
|
|
187
|
+
return entries;
|
|
188
|
+
}
|
|
189
|
+
|
|
46
190
|
export async function runDbSchemaGenerate(opts: SchemaGenerateOpts): Promise<void> {
|
|
47
191
|
const found = findProjectConfig();
|
|
48
192
|
if (!found) fail('No linked project. Run `ductape link` from your project directory.');
|
|
49
193
|
|
|
50
194
|
const { dir } = found;
|
|
51
|
-
const
|
|
195
|
+
const schemaPath = getSchemaPath(dir);
|
|
196
|
+
|
|
197
|
+
let schemaEntries: SchemaEntry[];
|
|
198
|
+
let wasScaffolded = false;
|
|
199
|
+
|
|
200
|
+
if (!fs.existsSync(schemaPath)) {
|
|
201
|
+
// Create directory and empty schema.json first
|
|
202
|
+
fs.mkdirSync(path.dirname(schemaPath), { recursive: true });
|
|
203
|
+
fs.writeFileSync(schemaPath, '[]', 'utf8');
|
|
204
|
+
wasScaffolded = true;
|
|
205
|
+
console.log(`Created ${path.relative(dir, schemaPath)}`);
|
|
206
|
+
|
|
207
|
+
// Try to hydrate from the cloud (requires --db and an active session)
|
|
208
|
+
schemaEntries = await tryHydrateFromCloud(dir, schemaPath, opts);
|
|
209
|
+
|
|
210
|
+
if (schemaEntries.length === 0) {
|
|
211
|
+
const hint = opts.db
|
|
212
|
+
? `(no existing collections found in "${opts.db}" for env "${requireSilent()?.project?.env_slug ?? 'default'}")`
|
|
213
|
+
: '(pass --db <tag> to auto-import existing collections from the cloud)';
|
|
214
|
+
console.log(`schema.json is empty ${hint}. Add your collection definitions and run \`ductape db schema generate\` again.`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
schemaEntries = loadSchemaFile(dir);
|
|
219
|
+
}
|
|
220
|
+
|
|
52
221
|
const targets = opts.db
|
|
53
222
|
? schemaEntries.filter((e) => e.db === opts.db)
|
|
54
223
|
: schemaEntries;
|
|
55
224
|
|
|
56
225
|
if (targets.length === 0) {
|
|
226
|
+
if (wasScaffolded) return; // already printed a message above
|
|
57
227
|
fail(opts.db ? `No db entry found for tag "${opts.db}".` : 'schema.json is empty.');
|
|
58
228
|
}
|
|
59
229
|
|
|
@@ -161,3 +331,7 @@ export async function runDbSchemaGenerate(opts: SchemaGenerateOpts): Promise<voi
|
|
|
161
331
|
console.log(`\nGenerated ${totalGenerated} migration file(s). Run \`ductape db migrate\` to apply them.`);
|
|
162
332
|
}
|
|
163
333
|
}
|
|
334
|
+
|
|
335
|
+
function requireSilent(): ReturnType<typeof requireSession> | null {
|
|
336
|
+
try { return requireSession(); } catch { return null; }
|
|
337
|
+
}
|