@tenora/multi-tenant 0.1.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/README.md +176 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1013 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +468 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import fs4 from "fs";
|
|
6
|
+
import knex2 from "knex";
|
|
7
|
+
import path4 from "path";
|
|
8
|
+
|
|
9
|
+
// src/knexFactory.ts
|
|
10
|
+
import knex from "knex";
|
|
11
|
+
import fs3 from "fs";
|
|
12
|
+
import path3 from "path";
|
|
13
|
+
|
|
14
|
+
// src/tenantRegistry.ts
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import path from "path";
|
|
17
|
+
|
|
18
|
+
// src/password.ts
|
|
19
|
+
import CryptoJS from "crypto-js";
|
|
20
|
+
var encryptPassword = (password, cipherKey) => {
|
|
21
|
+
if (typeof cipherKey !== "string" || cipherKey.length === 0) {
|
|
22
|
+
throw new Error("Tenora: cipherKey must be a non-empty string.");
|
|
23
|
+
}
|
|
24
|
+
return CryptoJS.AES.encrypt(password, cipherKey).toString();
|
|
25
|
+
};
|
|
26
|
+
var decryptPassword = (encryptedPassword, cipherKey) => {
|
|
27
|
+
if (typeof cipherKey !== "string" || cipherKey.length === 0) {
|
|
28
|
+
throw new Error("Tenora: cipherKey must be a non-empty string.");
|
|
29
|
+
}
|
|
30
|
+
const bytes = CryptoJS.AES.decrypt(encryptedPassword, cipherKey);
|
|
31
|
+
return bytes.toString(CryptoJS.enc.Utf8);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// src/tenantRegistry.ts
|
|
35
|
+
var REGISTRY_MARKER = "tenora:registry";
|
|
36
|
+
var DEFAULT_REGISTRY = {
|
|
37
|
+
table: "tenora_tenants",
|
|
38
|
+
idColumn: "id",
|
|
39
|
+
passwordColumn: "password",
|
|
40
|
+
encryptedPasswordColumn: "encrypted_password",
|
|
41
|
+
createdAtColumn: "created_at",
|
|
42
|
+
updatedAtColumn: "updated_at"
|
|
43
|
+
};
|
|
44
|
+
var resolveRegistry = (options) => ({
|
|
45
|
+
...DEFAULT_REGISTRY,
|
|
46
|
+
...options.registry ?? {}
|
|
47
|
+
});
|
|
48
|
+
var resolveEncrypt = (options) => {
|
|
49
|
+
if (options.encryptPassword) return options.encryptPassword;
|
|
50
|
+
const key = process.env.TENORA_KEY;
|
|
51
|
+
if (key) {
|
|
52
|
+
return (plain) => encryptPassword(plain, key);
|
|
53
|
+
}
|
|
54
|
+
return void 0;
|
|
55
|
+
};
|
|
56
|
+
var resolveDecrypt = (options) => {
|
|
57
|
+
if (options.decryptPassword) return options.decryptPassword;
|
|
58
|
+
const key = process.env.TENORA_KEY;
|
|
59
|
+
if (key) {
|
|
60
|
+
return (encrypted) => decryptPassword(encrypted, key);
|
|
61
|
+
}
|
|
62
|
+
return void 0;
|
|
63
|
+
};
|
|
64
|
+
var listMigrationFiles = (dir) => {
|
|
65
|
+
if (!fs.existsSync(dir)) return [];
|
|
66
|
+
return fs.readdirSync(dir).filter((name) => name.endsWith(".js") || name.endsWith(".ts")).map((name) => path.join(dir, name));
|
|
67
|
+
};
|
|
68
|
+
var ensureRegistryMigration = (options) => {
|
|
69
|
+
const configuredDir = options.base.migrationsDir;
|
|
70
|
+
const migrationsDir = configuredDir ? path.isAbsolute(configuredDir) ? configuredDir : path.join(process.cwd(), configuredDir) : void 0;
|
|
71
|
+
if (!migrationsDir) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"Tenora: base.migrationsDir is required to create the tenant registry migration."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const registry = resolveRegistry(options);
|
|
77
|
+
const files = listMigrationFiles(migrationsDir);
|
|
78
|
+
const hasRegistry = files.some((file) => {
|
|
79
|
+
try {
|
|
80
|
+
return fs.readFileSync(file, "utf8").includes(REGISTRY_MARKER);
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
if (hasRegistry) {
|
|
86
|
+
return { created: false };
|
|
87
|
+
}
|
|
88
|
+
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
89
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
|
|
90
|
+
const fileName = `${timestamp}_create_tenant_registry.js`;
|
|
91
|
+
const filePath = path.join(migrationsDir, fileName);
|
|
92
|
+
const migration = `// ${REGISTRY_MARKER}
|
|
93
|
+
export const up = (knex) =>
|
|
94
|
+
knex.schema.createTable("${registry.table}", (t) => {
|
|
95
|
+
t.string("${registry.idColumn}").primary();
|
|
96
|
+
t.string("${registry.passwordColumn}");
|
|
97
|
+
t.string("${registry.encryptedPasswordColumn}");
|
|
98
|
+
t.timestamp("${registry.createdAtColumn}", { useTz: true }).defaultTo(knex.fn.now());
|
|
99
|
+
t.timestamp("${registry.updatedAtColumn}", { useTz: true }).defaultTo(knex.fn.now());
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export const down = (knex) => knex.schema.dropTableIfExists("${registry.table}");
|
|
103
|
+
`;
|
|
104
|
+
fs.writeFileSync(filePath, migration);
|
|
105
|
+
return { created: true, filePath };
|
|
106
|
+
};
|
|
107
|
+
var ensureRegistryTable = async (base, options) => {
|
|
108
|
+
const registry = resolveRegistry(options);
|
|
109
|
+
const exists = await base.schema.hasTable(registry.table);
|
|
110
|
+
if (!exists) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Tenora: tenant registry table "${registry.table}" not found. Run 'tenora migrate' to create it.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
var listTenantsFromRegistry = async (base, options) => {
|
|
117
|
+
const registry = resolveRegistry(options);
|
|
118
|
+
await ensureRegistryTable(base, options);
|
|
119
|
+
const rows = await base(registry.table).select({
|
|
120
|
+
id: registry.idColumn,
|
|
121
|
+
password: registry.passwordColumn,
|
|
122
|
+
encryptedPassword: registry.encryptedPasswordColumn
|
|
123
|
+
});
|
|
124
|
+
return rows;
|
|
125
|
+
};
|
|
126
|
+
var upsertTenantInRegistry = async (base, options, tenantId, password) => {
|
|
127
|
+
const registry = resolveRegistry(options);
|
|
128
|
+
await ensureRegistryTable(base, options);
|
|
129
|
+
const encrypt = resolveEncrypt(options);
|
|
130
|
+
const encrypted = password && encrypt ? encrypt(password) : void 0;
|
|
131
|
+
const record = {
|
|
132
|
+
[registry.idColumn]: tenantId
|
|
133
|
+
};
|
|
134
|
+
if (encrypted) {
|
|
135
|
+
record[registry.encryptedPasswordColumn] = encrypted;
|
|
136
|
+
} else if (password) {
|
|
137
|
+
record[registry.passwordColumn] = password;
|
|
138
|
+
}
|
|
139
|
+
await base(registry.table).insert(record).onConflict(registry.idColumn).merge(record);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/configLoader.ts
|
|
143
|
+
import fs2 from "fs";
|
|
144
|
+
import path2 from "path";
|
|
145
|
+
import { pathToFileURL } from "url";
|
|
146
|
+
import { createRequire } from "module";
|
|
147
|
+
var require2 = createRequire(import.meta.url);
|
|
148
|
+
var DEFAULT_CONFIG_FILES = [
|
|
149
|
+
"tenora.config.js",
|
|
150
|
+
"tenora.config.mjs",
|
|
151
|
+
"tenora.config.ts"
|
|
152
|
+
];
|
|
153
|
+
var resolveConfigPath = (explicitPath) => {
|
|
154
|
+
const cwd = process.cwd();
|
|
155
|
+
if (explicitPath) {
|
|
156
|
+
return path2.isAbsolute(explicitPath) ? explicitPath : path2.join(cwd, explicitPath);
|
|
157
|
+
}
|
|
158
|
+
for (const file of DEFAULT_CONFIG_FILES) {
|
|
159
|
+
const full = path2.join(cwd, file);
|
|
160
|
+
if (fs2.existsSync(full)) return full;
|
|
161
|
+
}
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Tenora: no config found. Looked for ${DEFAULT_CONFIG_FILES.join(", ")} in ${cwd}.`
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
var loadConfigModuleSync = (fullPath) => {
|
|
167
|
+
const ext = path2.extname(fullPath);
|
|
168
|
+
try {
|
|
169
|
+
const mod = require2(fullPath);
|
|
170
|
+
return mod;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (ext === ".mjs" || err?.code === "ERR_REQUIRE_ESM") {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Tenora: ${path2.basename(fullPath)} is ESM. Use createTenoraFactoryAsync() or pass the config directly.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (ext === ".ts" || err?.code === "ERR_UNKNOWN_FILE_EXTENSION") {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Tenora: ${path2.basename(fullPath)} is TypeScript. Use createTenoraFactoryAsync() with a TS loader (tsx/ts-node), or pass the config directly.`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
var loadConfigModuleAsync = async (fullPath) => {
|
|
186
|
+
const href = pathToFileURL(fullPath).href;
|
|
187
|
+
return import(href);
|
|
188
|
+
};
|
|
189
|
+
var unwrapConfig = (mod) => mod.default ?? mod.config ?? mod;
|
|
190
|
+
|
|
191
|
+
// src/knexFactory.ts
|
|
192
|
+
var resolveClient = (value) => value ?? "pg";
|
|
193
|
+
var isPostgresClient = (client) => client === "pg" || client === "postgres" || client === "postgresql";
|
|
194
|
+
var isMysqlClient = (client) => client === "mysql" || client === "mysql2" || client === "mariadb";
|
|
195
|
+
var isSqliteClient = (client) => client === "sqlite3" || client === "better-sqlite3" || client === "sqlite";
|
|
196
|
+
var isMssqlClient = (client) => client === "mssql" || client === "sqlserver";
|
|
197
|
+
var normalizePassword = (value) => {
|
|
198
|
+
if (value === void 0 || value === null) return void 0;
|
|
199
|
+
if (typeof value === "string") return value;
|
|
200
|
+
return String(value);
|
|
201
|
+
};
|
|
202
|
+
var escapePgIdent = (value) => value.replace(/"/g, '""');
|
|
203
|
+
var escapeMysqlIdent = (value) => value.replace(/`/g, "``");
|
|
204
|
+
var escapeMssqlIdent = (value) => value.replace(/]/g, "]]");
|
|
205
|
+
var escapeSqlString = (value) => value.replace(/'/g, "''");
|
|
206
|
+
var loadDefaultConfig = () => {
|
|
207
|
+
const explicit = process.env.TENORA_CONFIG;
|
|
208
|
+
const fullPath = resolveConfigPath(explicit ?? void 0);
|
|
209
|
+
const module = loadConfigModuleSync(fullPath);
|
|
210
|
+
const cfg = unwrapConfig(module);
|
|
211
|
+
if (!cfg) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Tenora: config file ${path3.basename(fullPath)} did not export a config object.`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return cfg;
|
|
217
|
+
};
|
|
218
|
+
var defaultPool = { min: 2, max: 10, acquireTimeoutMillis: 6e4, idleTimeoutMillis: 6e4 };
|
|
219
|
+
var buildTenoraFactory = (resolved) => {
|
|
220
|
+
const { base, tenant = {}, knexOptions = {} } = resolved;
|
|
221
|
+
const cache = /* @__PURE__ */ new Map();
|
|
222
|
+
const basePassword = normalizePassword(base.password);
|
|
223
|
+
const client = resolveClient(base.client);
|
|
224
|
+
const resolveBaseDatabaseName2 = () => {
|
|
225
|
+
if (base.connection) {
|
|
226
|
+
const conn = base.connection;
|
|
227
|
+
if (isSqliteClient(client)) {
|
|
228
|
+
const filename = conn.filename ?? base.database;
|
|
229
|
+
if (!filename) {
|
|
230
|
+
throw new Error("Tenora: base.database or base.connection.filename is required for SQLite.");
|
|
231
|
+
}
|
|
232
|
+
return filename;
|
|
233
|
+
}
|
|
234
|
+
const dbName = conn.database ?? base.database;
|
|
235
|
+
if (!dbName) {
|
|
236
|
+
throw new Error("Tenora: base.database is required.");
|
|
237
|
+
}
|
|
238
|
+
return dbName;
|
|
239
|
+
}
|
|
240
|
+
if (!base.database) {
|
|
241
|
+
throw new Error("Tenora: base.database is required.");
|
|
242
|
+
}
|
|
243
|
+
return base.database;
|
|
244
|
+
};
|
|
245
|
+
const applyConnectionDefaults2 = (conn) => {
|
|
246
|
+
if (conn.user === void 0 && base.user !== void 0) conn.user = base.user;
|
|
247
|
+
if (conn.port === void 0 && base.port !== void 0) conn.port = base.port;
|
|
248
|
+
if (isMssqlClient(client)) {
|
|
249
|
+
if (conn.server === void 0 && base.host !== void 0) conn.server = base.host;
|
|
250
|
+
} else if (conn.host === void 0 && base.host !== void 0) {
|
|
251
|
+
conn.host = base.host;
|
|
252
|
+
}
|
|
253
|
+
if (conn.ssl === void 0 && base.ssl !== void 0) conn.ssl = base.ssl;
|
|
254
|
+
const normalized = normalizePassword(conn.password ?? basePassword);
|
|
255
|
+
if (normalized !== void 0) {
|
|
256
|
+
conn.password = normalized;
|
|
257
|
+
} else {
|
|
258
|
+
delete conn.password;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const buildBaseConnection2 = (databaseOverride) => {
|
|
262
|
+
if (base.connection) {
|
|
263
|
+
const conn2 = { ...base.connection };
|
|
264
|
+
applyConnectionDefaults2(conn2);
|
|
265
|
+
if (databaseOverride !== void 0) {
|
|
266
|
+
if (isSqliteClient(client)) {
|
|
267
|
+
conn2.filename = databaseOverride;
|
|
268
|
+
delete conn2.database;
|
|
269
|
+
} else {
|
|
270
|
+
conn2.database = databaseOverride;
|
|
271
|
+
}
|
|
272
|
+
} else if (isSqliteClient(client)) {
|
|
273
|
+
if (conn2.filename === void 0 && base.database) conn2.filename = base.database;
|
|
274
|
+
} else if (conn2.database === void 0 && base.database) {
|
|
275
|
+
conn2.database = base.database;
|
|
276
|
+
}
|
|
277
|
+
return conn2;
|
|
278
|
+
}
|
|
279
|
+
if (isSqliteClient(client)) {
|
|
280
|
+
const filename = databaseOverride ?? base.database;
|
|
281
|
+
if (!filename) {
|
|
282
|
+
throw new Error("Tenora: base.database is required for SQLite.");
|
|
283
|
+
}
|
|
284
|
+
return { filename };
|
|
285
|
+
}
|
|
286
|
+
if (!base.host || !base.user || !base.database) {
|
|
287
|
+
throw new Error("Tenora: base connection is incomplete. Provide base.connection or host/user/database.");
|
|
288
|
+
}
|
|
289
|
+
const conn = {
|
|
290
|
+
...isMssqlClient(client) ? { server: base.host } : { host: base.host },
|
|
291
|
+
port: base.port,
|
|
292
|
+
user: base.user,
|
|
293
|
+
database: databaseOverride ?? base.database,
|
|
294
|
+
ssl: base.ssl ?? false
|
|
295
|
+
};
|
|
296
|
+
if (basePassword !== void 0) conn.password = basePassword;
|
|
297
|
+
return conn;
|
|
298
|
+
};
|
|
299
|
+
const baseKnexConfig = {
|
|
300
|
+
client,
|
|
301
|
+
useNullAsDefault: true,
|
|
302
|
+
connection: buildBaseConnection2(resolveBaseDatabaseName2()),
|
|
303
|
+
pool: base.pool ?? defaultPool,
|
|
304
|
+
migrations: base.migrationsDir ? { directory: base.migrationsDir } : void 0,
|
|
305
|
+
seeds: base.seedsDir ? { directory: base.seedsDir } : void 0,
|
|
306
|
+
...knexOptions
|
|
307
|
+
};
|
|
308
|
+
const baseClient = knex(baseKnexConfig);
|
|
309
|
+
const resolveTenantDatabaseName = (tenantId) => tenant.databaseName ? tenant.databaseName(tenantId) : tenantId;
|
|
310
|
+
const resolveSqliteTenantFilename = (tenantId) => {
|
|
311
|
+
const baseDb = resolveBaseDatabaseName2();
|
|
312
|
+
const baseDir = tenant.databaseDir ?? path3.dirname(baseDb ?? process.cwd());
|
|
313
|
+
const name = tenant.databaseName ? tenant.databaseName(tenantId) : `${tenantId}${tenant.databaseSuffix ?? ".sqlite"}`;
|
|
314
|
+
return path3.isAbsolute(name) ? name : path3.join(baseDir, name);
|
|
315
|
+
};
|
|
316
|
+
const buildTenantConfig = (tenantId, password) => {
|
|
317
|
+
const tenantPassword = normalizePassword(password ?? basePassword);
|
|
318
|
+
const hasTenantPassword = password !== void 0 && password !== null;
|
|
319
|
+
const tenantDb = isSqliteClient(client) ? resolveSqliteTenantFilename(tenantId) : resolveTenantDatabaseName(tenantId);
|
|
320
|
+
const connection = buildBaseConnection2(tenantDb);
|
|
321
|
+
if (!isSqliteClient(client) && hasTenantPassword) {
|
|
322
|
+
connection.user = `${tenant.userPrefix ?? "user_"}${tenantId}`;
|
|
323
|
+
if (tenantPassword !== void 0) {
|
|
324
|
+
connection.password = tenantPassword;
|
|
325
|
+
} else {
|
|
326
|
+
delete connection.password;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (tenant.ssl !== void 0) {
|
|
330
|
+
connection.ssl = tenant.ssl;
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
client,
|
|
334
|
+
useNullAsDefault: true,
|
|
335
|
+
connection,
|
|
336
|
+
pool: tenant.pool ?? base.pool ?? defaultPool,
|
|
337
|
+
migrations: tenant.migrationsDir ? { directory: tenant.migrationsDir } : void 0,
|
|
338
|
+
seeds: tenant.seedsDir ? { directory: tenant.seedsDir } : void 0,
|
|
339
|
+
...knexOptions
|
|
340
|
+
};
|
|
341
|
+
};
|
|
342
|
+
const getTenant = (tenantId, password) => {
|
|
343
|
+
const cached = cache.get(tenantId);
|
|
344
|
+
if (cached) return cached;
|
|
345
|
+
const client2 = knex(buildTenantConfig(tenantId, password));
|
|
346
|
+
cache.set(tenantId, client2);
|
|
347
|
+
return client2;
|
|
348
|
+
};
|
|
349
|
+
const destroyTenant = async (tenantId) => {
|
|
350
|
+
const client2 = cache.get(tenantId);
|
|
351
|
+
if (client2) {
|
|
352
|
+
await client2.destroy();
|
|
353
|
+
cache.delete(tenantId);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
const destroyAll = async () => {
|
|
357
|
+
await Promise.all([...cache.values()].map((k) => k.destroy()));
|
|
358
|
+
cache.clear();
|
|
359
|
+
await baseClient.destroy();
|
|
360
|
+
};
|
|
361
|
+
const createTenantDb = async (tenantId, password) => {
|
|
362
|
+
await ensureRegistryTable(baseClient, resolved);
|
|
363
|
+
const tenantPassword = normalizePassword(password);
|
|
364
|
+
const hasTenantPassword = password !== void 0 && password !== null;
|
|
365
|
+
if (isSqliteClient(client)) {
|
|
366
|
+
const filename = resolveSqliteTenantFilename(tenantId);
|
|
367
|
+
if (filename !== ":memory:") {
|
|
368
|
+
fs3.mkdirSync(path3.dirname(filename), { recursive: true });
|
|
369
|
+
if (!fs3.existsSync(filename)) {
|
|
370
|
+
fs3.closeSync(fs3.openSync(filename, "a"));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
const adminDb = base.adminDatabase ?? (isPostgresClient(client) ? "postgres" : isMysqlClient(client) ? "mysql" : isMssqlClient(client) ? "master" : resolveBaseDatabaseName2());
|
|
375
|
+
const admin = knex({
|
|
376
|
+
...baseKnexConfig,
|
|
377
|
+
connection: buildBaseConnection2(adminDb)
|
|
378
|
+
});
|
|
379
|
+
try {
|
|
380
|
+
if (isPostgresClient(client)) {
|
|
381
|
+
const result = await admin.raw(`SELECT 1 FROM pg_database WHERE datname = ?`, [tenantId]);
|
|
382
|
+
if (result?.rows?.length) {
|
|
383
|
+
throw new Error(`Database "${tenantId}" already exists`);
|
|
384
|
+
}
|
|
385
|
+
const safeDb = escapePgIdent(tenantId);
|
|
386
|
+
await admin.raw(`CREATE DATABASE "${safeDb}"`);
|
|
387
|
+
if (tenantPassword && hasTenantPassword) {
|
|
388
|
+
const userName = `${tenant.userPrefix ?? "user_"}${tenantId}`;
|
|
389
|
+
const safeUser = escapePgIdent(userName);
|
|
390
|
+
const safePwd = escapeSqlString(tenantPassword);
|
|
391
|
+
await admin.raw(`CREATE USER "${safeUser}" WITH PASSWORD '${safePwd}'`);
|
|
392
|
+
await admin.raw(`GRANT ALL PRIVILEGES ON DATABASE "${safeDb}" TO "${safeUser}"`);
|
|
393
|
+
}
|
|
394
|
+
} else if (isMysqlClient(client)) {
|
|
395
|
+
const result = await admin.raw(
|
|
396
|
+
`SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?`,
|
|
397
|
+
[tenantId]
|
|
398
|
+
);
|
|
399
|
+
if (result?.[0]?.length) {
|
|
400
|
+
throw new Error(`Database "${tenantId}" already exists`);
|
|
401
|
+
}
|
|
402
|
+
const safeDb = escapeMysqlIdent(tenantId);
|
|
403
|
+
await admin.raw(`CREATE DATABASE \`${safeDb}\``);
|
|
404
|
+
if (tenantPassword && hasTenantPassword) {
|
|
405
|
+
const userName = `${tenant.userPrefix ?? "user_"}${tenantId}`;
|
|
406
|
+
const safeUser = escapeSqlString(userName);
|
|
407
|
+
const safePwd = escapeSqlString(tenantPassword);
|
|
408
|
+
await admin.raw(`CREATE USER IF NOT EXISTS '${safeUser}'@'%' IDENTIFIED BY '${safePwd}'`);
|
|
409
|
+
await admin.raw(`GRANT ALL PRIVILEGES ON \`${safeDb}\`.* TO '${safeUser}'@'%'`);
|
|
410
|
+
}
|
|
411
|
+
} else if (isMssqlClient(client)) {
|
|
412
|
+
const safeDb = escapeMssqlIdent(tenantId);
|
|
413
|
+
await admin.raw(
|
|
414
|
+
`IF DB_ID(N'${escapeSqlString(tenantId)}') IS NULL CREATE DATABASE [${safeDb}]`
|
|
415
|
+
);
|
|
416
|
+
if (tenantPassword && hasTenantPassword) {
|
|
417
|
+
const userName = `${tenant.userPrefix ?? "user_"}${tenantId}`;
|
|
418
|
+
const safeUser = escapeMssqlIdent(userName);
|
|
419
|
+
const safePwd = escapeSqlString(tenantPassword);
|
|
420
|
+
await admin.raw(
|
|
421
|
+
`IF NOT EXISTS (SELECT name FROM sys.server_principals WHERE name = N'${escapeSqlString(userName)}') CREATE LOGIN [${safeUser}] WITH PASSWORD = '${safePwd}'`
|
|
422
|
+
);
|
|
423
|
+
const tenantAdmin = knex({
|
|
424
|
+
...baseKnexConfig,
|
|
425
|
+
connection: buildBaseConnection2(tenantId)
|
|
426
|
+
});
|
|
427
|
+
try {
|
|
428
|
+
await tenantAdmin.raw(
|
|
429
|
+
`IF NOT EXISTS (SELECT name FROM sys.database_principals WHERE name = N'${escapeSqlString(userName)}') CREATE USER [${safeUser}] FOR LOGIN [${safeUser}]`
|
|
430
|
+
);
|
|
431
|
+
await tenantAdmin.raw(`EXEC sp_addrolemember 'db_owner', '${safeUser}'`);
|
|
432
|
+
} finally {
|
|
433
|
+
await tenantAdmin.destroy();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
throw new Error(
|
|
438
|
+
`Tenora: createTenantDb is only supported for Postgres, MySQL/MariaDB, SQLite, and SQL Server clients (got "${client}").`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
} finally {
|
|
442
|
+
await admin.destroy();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const tenantKnex = knex(buildTenantConfig(tenantId, tenantPassword));
|
|
446
|
+
try {
|
|
447
|
+
if (tenant.migrationsDir) {
|
|
448
|
+
await tenantKnex.migrate.latest();
|
|
449
|
+
}
|
|
450
|
+
if (tenant.seedsDir) {
|
|
451
|
+
await tenantKnex.seed.run();
|
|
452
|
+
}
|
|
453
|
+
} finally {
|
|
454
|
+
await tenantKnex.destroy();
|
|
455
|
+
cache.delete(tenantId);
|
|
456
|
+
}
|
|
457
|
+
await upsertTenantInRegistry(baseClient, resolved, tenantId, password);
|
|
458
|
+
};
|
|
459
|
+
return {
|
|
460
|
+
getBase: () => baseClient,
|
|
461
|
+
getTenant,
|
|
462
|
+
createTenantDb,
|
|
463
|
+
destroyTenant,
|
|
464
|
+
destroyAll
|
|
465
|
+
};
|
|
466
|
+
};
|
|
467
|
+
var createTenoraFactory = (options) => {
|
|
468
|
+
const resolved = options ?? loadDefaultConfig();
|
|
469
|
+
return buildTenoraFactory(resolved);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// src/cli.ts
|
|
473
|
+
var program = new Command();
|
|
474
|
+
var ensureRegistryMigrationIfNeeded = (cfg) => {
|
|
475
|
+
const result = ensureRegistryMigration(cfg);
|
|
476
|
+
if (result.created) {
|
|
477
|
+
console.log(
|
|
478
|
+
`Tenora: created tenant registry migration at ${result.filePath}. Review it (rename if desired), then run 'tenora migrate' again.`
|
|
479
|
+
);
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
};
|
|
484
|
+
var findNearestPackageJson = (startDir) => {
|
|
485
|
+
let current = startDir;
|
|
486
|
+
while (true) {
|
|
487
|
+
const candidate = path4.join(current, "package.json");
|
|
488
|
+
if (fs4.existsSync(candidate)) return candidate;
|
|
489
|
+
const parent = path4.dirname(current);
|
|
490
|
+
if (parent === current) return void 0;
|
|
491
|
+
current = parent;
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
var detectModuleType = () => {
|
|
495
|
+
const pkgPath = findNearestPackageJson(process.cwd());
|
|
496
|
+
if (!pkgPath) return "cjs";
|
|
497
|
+
try {
|
|
498
|
+
const raw = fs4.readFileSync(pkgPath, "utf8");
|
|
499
|
+
const json = JSON.parse(raw);
|
|
500
|
+
return json.type === "module" ? "esm" : "cjs";
|
|
501
|
+
} catch {
|
|
502
|
+
return "cjs";
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
var resolveTemplateModuleType = (opts) => {
|
|
506
|
+
if (opts.esm && opts.cjs) {
|
|
507
|
+
throw new Error("Tenora: choose only one of --esm or --cjs.");
|
|
508
|
+
}
|
|
509
|
+
if (opts.esm) return "esm";
|
|
510
|
+
if (opts.cjs) return "cjs";
|
|
511
|
+
return detectModuleType();
|
|
512
|
+
};
|
|
513
|
+
var resolveClient2 = (value) => value ?? "pg";
|
|
514
|
+
var isPostgresClient2 = (client) => client === "pg" || client === "postgres" || client === "postgresql";
|
|
515
|
+
var isMysqlClient2 = (client) => client === "mysql" || client === "mysql2" || client === "mariadb";
|
|
516
|
+
var isSqliteClient2 = (client) => client === "sqlite3" || client === "better-sqlite3" || client === "sqlite";
|
|
517
|
+
var isMssqlClient2 = (client) => client === "mssql" || client === "sqlserver";
|
|
518
|
+
var normalizePassword2 = (value) => {
|
|
519
|
+
if (value === void 0 || value === null) return void 0;
|
|
520
|
+
if (typeof value === "string") return value;
|
|
521
|
+
return String(value);
|
|
522
|
+
};
|
|
523
|
+
var escapePgIdent2 = (value) => value.replace(/"/g, '""');
|
|
524
|
+
var escapeMysqlIdent2 = (value) => value.replace(/`/g, "``");
|
|
525
|
+
var escapeMssqlIdent2 = (value) => value.replace(/]/g, "]]");
|
|
526
|
+
var resolveBaseDatabaseName = (cfg) => {
|
|
527
|
+
const client = resolveClient2(cfg.base.client);
|
|
528
|
+
if (cfg.base.connection) {
|
|
529
|
+
const conn = cfg.base.connection;
|
|
530
|
+
if (isSqliteClient2(client)) {
|
|
531
|
+
const filename = conn.filename ?? cfg.base.database;
|
|
532
|
+
if (!filename) {
|
|
533
|
+
throw new Error("Tenora: base.database or base.connection.filename is required for SQLite.");
|
|
534
|
+
}
|
|
535
|
+
return filename;
|
|
536
|
+
}
|
|
537
|
+
const dbName = conn.database ?? cfg.base.database;
|
|
538
|
+
if (!dbName) throw new Error("Tenora: base.database is required.");
|
|
539
|
+
return dbName;
|
|
540
|
+
}
|
|
541
|
+
if (!cfg.base.database) {
|
|
542
|
+
throw new Error("Tenora: base.database is required.");
|
|
543
|
+
}
|
|
544
|
+
return cfg.base.database;
|
|
545
|
+
};
|
|
546
|
+
var applyConnectionDefaults = (cfg, conn) => {
|
|
547
|
+
const client = resolveClient2(cfg.base.client);
|
|
548
|
+
if (conn.user === void 0 && cfg.base.user !== void 0) conn.user = cfg.base.user;
|
|
549
|
+
if (conn.port === void 0 && cfg.base.port !== void 0) conn.port = cfg.base.port;
|
|
550
|
+
if (isMssqlClient2(client)) {
|
|
551
|
+
if (conn.server === void 0 && cfg.base.host !== void 0) conn.server = cfg.base.host;
|
|
552
|
+
} else if (conn.host === void 0 && cfg.base.host !== void 0) {
|
|
553
|
+
conn.host = cfg.base.host;
|
|
554
|
+
}
|
|
555
|
+
if (conn.ssl === void 0 && cfg.base.ssl !== void 0) conn.ssl = cfg.base.ssl;
|
|
556
|
+
const normalized = normalizePassword2(conn.password ?? cfg.base.password);
|
|
557
|
+
if (normalized !== void 0) {
|
|
558
|
+
conn.password = normalized;
|
|
559
|
+
} else {
|
|
560
|
+
delete conn.password;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
var buildBaseConnection = (cfg, databaseOverride) => {
|
|
564
|
+
const client = resolveClient2(cfg.base.client);
|
|
565
|
+
if (cfg.base.connection) {
|
|
566
|
+
const conn2 = { ...cfg.base.connection };
|
|
567
|
+
applyConnectionDefaults(cfg, conn2);
|
|
568
|
+
if (databaseOverride !== void 0) {
|
|
569
|
+
if (isSqliteClient2(client)) {
|
|
570
|
+
conn2.filename = databaseOverride;
|
|
571
|
+
delete conn2.database;
|
|
572
|
+
} else {
|
|
573
|
+
conn2.database = databaseOverride;
|
|
574
|
+
}
|
|
575
|
+
} else if (isSqliteClient2(client)) {
|
|
576
|
+
if (conn2.filename === void 0 && cfg.base.database) conn2.filename = cfg.base.database;
|
|
577
|
+
} else if (conn2.database === void 0 && cfg.base.database) {
|
|
578
|
+
conn2.database = cfg.base.database;
|
|
579
|
+
}
|
|
580
|
+
return conn2;
|
|
581
|
+
}
|
|
582
|
+
if (isSqliteClient2(client)) {
|
|
583
|
+
const filename = databaseOverride ?? cfg.base.database;
|
|
584
|
+
if (!filename) {
|
|
585
|
+
throw new Error("Tenora: base.database is required for SQLite.");
|
|
586
|
+
}
|
|
587
|
+
return { filename };
|
|
588
|
+
}
|
|
589
|
+
if (!cfg.base.host || !cfg.base.user || !cfg.base.database) {
|
|
590
|
+
throw new Error("Tenora: base connection is incomplete. Provide base.connection or host/user/database.");
|
|
591
|
+
}
|
|
592
|
+
const conn = {
|
|
593
|
+
...isMssqlClient2(client) ? { server: cfg.base.host } : { host: cfg.base.host },
|
|
594
|
+
port: cfg.base.port,
|
|
595
|
+
user: cfg.base.user,
|
|
596
|
+
database: databaseOverride ?? cfg.base.database,
|
|
597
|
+
ssl: cfg.base.ssl ?? false
|
|
598
|
+
};
|
|
599
|
+
const basePassword = normalizePassword2(cfg.base.password);
|
|
600
|
+
if (basePassword !== void 0) conn.password = basePassword;
|
|
601
|
+
return conn;
|
|
602
|
+
};
|
|
603
|
+
var normalizeCreatedPath = (created) => Array.isArray(created) ? created[0] : created;
|
|
604
|
+
var tokenizeName = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
605
|
+
var joinTokens = (tokens) => tokens.join("_");
|
|
606
|
+
var splitColumns = (tokens) => {
|
|
607
|
+
const columns = [];
|
|
608
|
+
let current = [];
|
|
609
|
+
for (const token of tokens) {
|
|
610
|
+
if (token === "and") {
|
|
611
|
+
if (current.length) {
|
|
612
|
+
columns.push(joinTokens(current));
|
|
613
|
+
current = [];
|
|
614
|
+
}
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
current.push(token);
|
|
618
|
+
}
|
|
619
|
+
if (current.length) columns.push(joinTokens(current));
|
|
620
|
+
return columns.filter(Boolean);
|
|
621
|
+
};
|
|
622
|
+
var inferCreateTable = (tokens) => {
|
|
623
|
+
const tableIdx = tokens.indexOf("table");
|
|
624
|
+
if (tableIdx > 0) return tokens[tableIdx - 1];
|
|
625
|
+
const createIdx = tokens.indexOf("create");
|
|
626
|
+
if (createIdx >= 0 && tokens[createIdx + 1]) return tokens[createIdx + 1];
|
|
627
|
+
return void 0;
|
|
628
|
+
};
|
|
629
|
+
var inferAlterAdd = (tokens) => {
|
|
630
|
+
const addIdx = tokens.indexOf("add");
|
|
631
|
+
const toIdx = tokens.indexOf("to");
|
|
632
|
+
if (addIdx === -1 || toIdx === -1 || toIdx <= addIdx + 1) return void 0;
|
|
633
|
+
const cols = splitColumns(tokens.slice(addIdx + 1, toIdx));
|
|
634
|
+
const tableTokens = tokens.slice(toIdx + 1);
|
|
635
|
+
if (!cols.length || !tableTokens.length) return void 0;
|
|
636
|
+
const table = tableTokens[tableTokens.length - 1] === "table" ? joinTokens(tableTokens.slice(0, -1)) : joinTokens(tableTokens);
|
|
637
|
+
if (!table) return void 0;
|
|
638
|
+
return { table, columns: cols };
|
|
639
|
+
};
|
|
640
|
+
var inferAlterRemove = (tokens) => {
|
|
641
|
+
const removeIdx = tokens.indexOf("remove");
|
|
642
|
+
const dropIdx = tokens.indexOf("drop");
|
|
643
|
+
const fromIdx = tokens.indexOf("from");
|
|
644
|
+
const startIdx = removeIdx !== -1 ? removeIdx : dropIdx;
|
|
645
|
+
if (startIdx === -1 || fromIdx === -1 || fromIdx <= startIdx + 1) return void 0;
|
|
646
|
+
const cols = splitColumns(tokens.slice(startIdx + 1, fromIdx));
|
|
647
|
+
const tableTokens = tokens.slice(fromIdx + 1);
|
|
648
|
+
if (!cols.length || !tableTokens.length) return void 0;
|
|
649
|
+
const table = tableTokens[tableTokens.length - 1] === "table" ? joinTokens(tableTokens.slice(0, -1)) : joinTokens(tableTokens);
|
|
650
|
+
if (!table) return void 0;
|
|
651
|
+
return { table, columns: cols };
|
|
652
|
+
};
|
|
653
|
+
var buildCreateTableTemplate = (table, moduleType) => moduleType === "esm" ? `export const up = (knex) =>
|
|
654
|
+
knex.schema.createTable("${table}", (t) => {
|
|
655
|
+
t.increments("id").primary();
|
|
656
|
+
t.timestamps(true, true);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
export const down = (knex) => knex.schema.dropTableIfExists("${table}");
|
|
660
|
+
` : `exports.up = (knex) =>
|
|
661
|
+
knex.schema.createTable("${table}", (t) => {
|
|
662
|
+
t.increments("id").primary();
|
|
663
|
+
t.timestamps(true, true);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
exports.down = (knex) => knex.schema.dropTableIfExists("${table}");
|
|
667
|
+
`;
|
|
668
|
+
var buildAlterAddTemplate = (table, columns, moduleType) => {
|
|
669
|
+
const addLines = columns.map((c) => ` t.string("${c}");`).join("\n");
|
|
670
|
+
const dropLines = columns.map((c) => ` t.dropColumn("${c}");`).join("\n");
|
|
671
|
+
return moduleType === "esm" ? `export const up = (knex) =>
|
|
672
|
+
knex.schema.alterTable("${table}", (t) => {
|
|
673
|
+
${addLines}
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
export const down = (knex) =>
|
|
677
|
+
knex.schema.alterTable("${table}", (t) => {
|
|
678
|
+
${dropLines}
|
|
679
|
+
});
|
|
680
|
+
` : `exports.up = (knex) =>
|
|
681
|
+
knex.schema.alterTable("${table}", (t) => {
|
|
682
|
+
${addLines}
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
exports.down = (knex) =>
|
|
686
|
+
knex.schema.alterTable("${table}", (t) => {
|
|
687
|
+
${dropLines}
|
|
688
|
+
});
|
|
689
|
+
`;
|
|
690
|
+
};
|
|
691
|
+
var buildAlterRemoveTemplate = (table, columns, moduleType) => {
|
|
692
|
+
const dropLines = columns.map((c) => ` t.dropColumn("${c}");`).join("\n");
|
|
693
|
+
const addLines = columns.map((c) => ` t.string("${c}");`).join("\n");
|
|
694
|
+
return moduleType === "esm" ? `export const up = (knex) =>
|
|
695
|
+
knex.schema.alterTable("${table}", (t) => {
|
|
696
|
+
${dropLines}
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
export const down = (knex) =>
|
|
700
|
+
knex.schema.alterTable("${table}", (t) => {
|
|
701
|
+
${addLines}
|
|
702
|
+
});
|
|
703
|
+
` : `exports.up = (knex) =>
|
|
704
|
+
knex.schema.alterTable("${table}", (t) => {
|
|
705
|
+
${dropLines}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
exports.down = (knex) =>
|
|
709
|
+
knex.schema.alterTable("${table}", (t) => {
|
|
710
|
+
${addLines}
|
|
711
|
+
});
|
|
712
|
+
`;
|
|
713
|
+
};
|
|
714
|
+
var buildDefaultMigrationTemplate = (moduleType) => moduleType === "esm" ? `export const up = (knex) => {
|
|
715
|
+
// TODO
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
export const down = (knex) => {
|
|
719
|
+
// TODO
|
|
720
|
+
};
|
|
721
|
+
` : `exports.up = (knex) => {
|
|
722
|
+
// TODO
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
exports.down = (knex) => {
|
|
726
|
+
// TODO
|
|
727
|
+
};
|
|
728
|
+
`;
|
|
729
|
+
var writeMigrationTemplate = (filePath, moduleType, name) => {
|
|
730
|
+
const tokens = tokenizeName(name);
|
|
731
|
+
const createTable = inferCreateTable(tokens);
|
|
732
|
+
const addPlan = inferAlterAdd(tokens);
|
|
733
|
+
const removePlan = inferAlterRemove(tokens);
|
|
734
|
+
const body = createTable ? buildCreateTableTemplate(createTable, moduleType) : addPlan ? buildAlterAddTemplate(addPlan.table, addPlan.columns, moduleType) : removePlan ? buildAlterRemoveTemplate(removePlan.table, removePlan.columns, moduleType) : buildDefaultMigrationTemplate(moduleType);
|
|
735
|
+
fs4.writeFileSync(filePath, body);
|
|
736
|
+
};
|
|
737
|
+
var writeSeedTemplate = (filePath, moduleType) => {
|
|
738
|
+
const body = moduleType === "esm" ? `export const seed = async (knex) => {
|
|
739
|
+
// TODO
|
|
740
|
+
};
|
|
741
|
+
` : `exports.seed = async (knex) => {
|
|
742
|
+
// TODO
|
|
743
|
+
};
|
|
744
|
+
`;
|
|
745
|
+
fs4.writeFileSync(filePath, body);
|
|
746
|
+
};
|
|
747
|
+
var makeKnexForDirs = (cfg, migrationsDir, seedsDir) => knex2({
|
|
748
|
+
client: resolveClient2(cfg.base.client),
|
|
749
|
+
useNullAsDefault: true,
|
|
750
|
+
connection: buildBaseConnection(cfg),
|
|
751
|
+
migrations: migrationsDir ? { directory: migrationsDir } : void 0,
|
|
752
|
+
seeds: seedsDir ? { directory: seedsDir } : void 0
|
|
753
|
+
});
|
|
754
|
+
var ensureBaseDatabase = async (cfg) => {
|
|
755
|
+
const client = resolveClient2(cfg.base.client);
|
|
756
|
+
const baseDb = resolveBaseDatabaseName(cfg);
|
|
757
|
+
if (isSqliteClient2(client)) {
|
|
758
|
+
if (baseDb === ":memory:") return;
|
|
759
|
+
fs4.mkdirSync(path4.dirname(baseDb), { recursive: true });
|
|
760
|
+
if (!fs4.existsSync(baseDb)) {
|
|
761
|
+
fs4.closeSync(fs4.openSync(baseDb, "a"));
|
|
762
|
+
console.log(`Tenora: created base database "${baseDb}"`);
|
|
763
|
+
}
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const adminDatabase = cfg.base.adminDatabase ?? (isPostgresClient2(client) ? "postgres" : isMysqlClient2(client) ? "mysql" : isMssqlClient2(client) ? "master" : baseDb);
|
|
767
|
+
const admin = knex2({
|
|
768
|
+
client,
|
|
769
|
+
useNullAsDefault: true,
|
|
770
|
+
connection: buildBaseConnection(cfg, adminDatabase)
|
|
771
|
+
});
|
|
772
|
+
try {
|
|
773
|
+
if (isPostgresClient2(client)) {
|
|
774
|
+
const result = await admin.raw(`SELECT 1 FROM pg_database WHERE datname = ?`, [baseDb]);
|
|
775
|
+
if (result?.rows?.length) return;
|
|
776
|
+
const safeName = escapePgIdent2(baseDb);
|
|
777
|
+
await admin.raw(`CREATE DATABASE "${safeName}"`);
|
|
778
|
+
} else if (isMysqlClient2(client)) {
|
|
779
|
+
const result = await admin.raw(
|
|
780
|
+
`SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?`,
|
|
781
|
+
[baseDb]
|
|
782
|
+
);
|
|
783
|
+
if (result?.[0]?.length) return;
|
|
784
|
+
const safeName = escapeMysqlIdent2(baseDb);
|
|
785
|
+
await admin.raw(`CREATE DATABASE \`${safeName}\``);
|
|
786
|
+
} else if (isMssqlClient2(client)) {
|
|
787
|
+
const result = await admin.raw(`SELECT name FROM sys.databases WHERE name = ?`, [baseDb]);
|
|
788
|
+
if (result?.[0]?.length) return;
|
|
789
|
+
const safeName = escapeMssqlIdent2(baseDb);
|
|
790
|
+
await admin.raw(`CREATE DATABASE [${safeName}]`);
|
|
791
|
+
} else {
|
|
792
|
+
throw new Error(
|
|
793
|
+
`Tenora: --create-base is only supported for Postgres, MySQL/MariaDB, SQLite, and SQL Server clients (got "${client}").`
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
console.log(`Tenora: created base database "${baseDb}"`);
|
|
797
|
+
} finally {
|
|
798
|
+
await admin.destroy();
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
var loadConfig = async (configPath) => {
|
|
802
|
+
const isDefault = configPath === "tenora.config.js";
|
|
803
|
+
let fullPath = path4.isAbsolute(configPath) ? configPath : path4.join(process.cwd(), configPath);
|
|
804
|
+
if (isDefault && !fs4.existsSync(fullPath)) {
|
|
805
|
+
fullPath = resolveConfigPath();
|
|
806
|
+
}
|
|
807
|
+
const module = await loadConfigModuleAsync(fullPath);
|
|
808
|
+
const cfg = unwrapConfig(module);
|
|
809
|
+
if (!cfg) {
|
|
810
|
+
throw new Error(`No config exported from ${fullPath}`);
|
|
811
|
+
}
|
|
812
|
+
return cfg;
|
|
813
|
+
};
|
|
814
|
+
var getTenantPassword = (tenant, decryptPassword2, cfg) => {
|
|
815
|
+
if (tenant.password) return tenant.password;
|
|
816
|
+
if (tenant.encryptedPassword) {
|
|
817
|
+
const resolver = decryptPassword2 ?? (cfg ? resolveDecrypt(cfg) : void 0);
|
|
818
|
+
if (resolver) return resolver(tenant.encryptedPassword);
|
|
819
|
+
}
|
|
820
|
+
return void 0;
|
|
821
|
+
};
|
|
822
|
+
var addBaseCommands = () => {
|
|
823
|
+
program.command("migrate:base").option("-c, --config <path>", "config file", "tenora.config.js").option("--create-base", "create base database if missing").description("Run base database migrations").action(async (opts) => {
|
|
824
|
+
const cfg = await loadConfig(opts.config);
|
|
825
|
+
if (opts.createBase) {
|
|
826
|
+
await ensureBaseDatabase(cfg);
|
|
827
|
+
}
|
|
828
|
+
if (ensureRegistryMigrationIfNeeded(cfg)) return;
|
|
829
|
+
const manager = createTenoraFactory(cfg);
|
|
830
|
+
try {
|
|
831
|
+
const base = manager.getBase();
|
|
832
|
+
const [, files] = await base.migrate.latest();
|
|
833
|
+
console.log(files.length ? files.join("\n") : "Base up to date");
|
|
834
|
+
} finally {
|
|
835
|
+
await manager.destroyAll();
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
program.command("migrate").option("-c, --config <path>", "config file", "tenora.config.js").option("--create-base", "create base database if missing").description("Run base database migrations (alias of migrate:base)").action(async (opts) => {
|
|
839
|
+
const cfg = await loadConfig(opts.config);
|
|
840
|
+
if (opts.createBase) {
|
|
841
|
+
await ensureBaseDatabase(cfg);
|
|
842
|
+
}
|
|
843
|
+
if (ensureRegistryMigrationIfNeeded(cfg)) return;
|
|
844
|
+
const manager = createTenoraFactory(cfg);
|
|
845
|
+
try {
|
|
846
|
+
const base = manager.getBase();
|
|
847
|
+
const [, files] = await base.migrate.latest();
|
|
848
|
+
console.log(files.length ? files.join("\n") : "Base up to date");
|
|
849
|
+
} finally {
|
|
850
|
+
await manager.destroyAll();
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
program.command("rollback:base").option("-c, --config <path>", "config file", "tenora.config.js").description("Rollback last base migration batch").action(async (opts) => {
|
|
854
|
+
const cfg = await loadConfig(opts.config);
|
|
855
|
+
const manager = createTenoraFactory(cfg);
|
|
856
|
+
try {
|
|
857
|
+
const base = manager.getBase();
|
|
858
|
+
const [, files] = await base.migrate.rollback();
|
|
859
|
+
console.log(files.length ? files.join("\n") : "Nothing to rollback");
|
|
860
|
+
} finally {
|
|
861
|
+
await manager.destroyAll();
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
program.command("rollback").option("-c, --config <path>", "config file", "tenora.config.js").description("Rollback last base migration batch (alias of rollback:base)").action(async (opts) => {
|
|
865
|
+
const cfg = await loadConfig(opts.config);
|
|
866
|
+
const manager = createTenoraFactory(cfg);
|
|
867
|
+
try {
|
|
868
|
+
const base = manager.getBase();
|
|
869
|
+
const [, files] = await base.migrate.rollback();
|
|
870
|
+
console.log(files.length ? files.join("\n") : "Nothing to rollback");
|
|
871
|
+
} finally {
|
|
872
|
+
await manager.destroyAll();
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
};
|
|
876
|
+
var addTenantCommands = () => {
|
|
877
|
+
program.command("migrate:tenants").option("-c, --config <path>", "config file", "tenora.config.js").option("--create-base", "create base database if missing").description("Run tenant database migrations for all tenants").action(async (opts) => {
|
|
878
|
+
const cfg = await loadConfig(opts.config);
|
|
879
|
+
if (opts.createBase) {
|
|
880
|
+
await ensureBaseDatabase(cfg);
|
|
881
|
+
}
|
|
882
|
+
if (ensureRegistryMigrationIfNeeded(cfg)) return;
|
|
883
|
+
const manager = createTenoraFactory(cfg);
|
|
884
|
+
try {
|
|
885
|
+
const tenants = await listTenantsFromRegistry(manager.getBase(), cfg);
|
|
886
|
+
for (const tenant of tenants) {
|
|
887
|
+
const pwd = getTenantPassword(tenant, cfg.decryptPassword, cfg);
|
|
888
|
+
const knex3 = manager.getTenant(tenant.id, pwd);
|
|
889
|
+
const [, files] = await knex3.migrate.latest();
|
|
890
|
+
console.log(`Tenant ${tenant.id}: ${files.length ? files.join(", ") : "up to date"}`);
|
|
891
|
+
await knex3.destroy();
|
|
892
|
+
}
|
|
893
|
+
} finally {
|
|
894
|
+
await manager.destroyAll();
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
program.command("rollback:tenants").option("-c, --config <path>", "config file", "tenora.config.js").description("Rollback last migration batch for all tenants").action(async (opts) => {
|
|
898
|
+
const cfg = await loadConfig(opts.config);
|
|
899
|
+
const manager = createTenoraFactory(cfg);
|
|
900
|
+
try {
|
|
901
|
+
const tenants = await listTenantsFromRegistry(manager.getBase(), cfg);
|
|
902
|
+
for (const tenant of tenants) {
|
|
903
|
+
const pwd = getTenantPassword(tenant, cfg.decryptPassword, cfg);
|
|
904
|
+
const knex3 = manager.getTenant(tenant.id, pwd);
|
|
905
|
+
const [, files] = await knex3.migrate.rollback();
|
|
906
|
+
console.log(`Tenant ${tenant.id}: ${files.length ? files.join(", ") : "Nothing to rollback"}`);
|
|
907
|
+
await knex3.destroy();
|
|
908
|
+
}
|
|
909
|
+
} finally {
|
|
910
|
+
await manager.destroyAll();
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
};
|
|
914
|
+
addBaseCommands();
|
|
915
|
+
addTenantCommands();
|
|
916
|
+
var runMakeMigration = async (scope, name, opts) => {
|
|
917
|
+
const cfg = await loadConfig(opts.config);
|
|
918
|
+
const dir = scope === "base" ? cfg.base.migrationsDir : cfg.tenant?.migrationsDir;
|
|
919
|
+
if (!dir) {
|
|
920
|
+
throw new Error(
|
|
921
|
+
`Tenora: ${scope === "base" ? "base.migrationsDir" : "tenant.migrationsDir"} is required to create migrations.`
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
const moduleType = resolveTemplateModuleType(opts);
|
|
925
|
+
const client = makeKnexForDirs(cfg, dir);
|
|
926
|
+
try {
|
|
927
|
+
const created = await client.migrate.make(name);
|
|
928
|
+
const file = normalizeCreatedPath(created);
|
|
929
|
+
writeMigrationTemplate(file, moduleType, name);
|
|
930
|
+
console.log(file);
|
|
931
|
+
} finally {
|
|
932
|
+
await client.destroy();
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
var runMakeSeed = async (scope, name, opts) => {
|
|
936
|
+
const cfg = await loadConfig(opts.config);
|
|
937
|
+
const dir = scope === "base" ? cfg.base.seedsDir : cfg.tenant?.seedsDir;
|
|
938
|
+
if (!dir) {
|
|
939
|
+
throw new Error(
|
|
940
|
+
`Tenora: ${scope === "base" ? "base.seedsDir" : "tenant.seedsDir"} is required to create seeds.`
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
const moduleType = resolveTemplateModuleType(opts);
|
|
944
|
+
const client = makeKnexForDirs(cfg, void 0, dir);
|
|
945
|
+
try {
|
|
946
|
+
const created = await client.seed.make(name);
|
|
947
|
+
const file = normalizeCreatedPath(created);
|
|
948
|
+
writeSeedTemplate(file, moduleType);
|
|
949
|
+
console.log(file);
|
|
950
|
+
} finally {
|
|
951
|
+
await client.destroy();
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
program.command("make:migration <name>").option("-c, --config <path>", "config file", "tenora.config.js").option("--esm", "force ESM template output").option("--cjs", "force CommonJS template output").description("Create a base migration file (alias of make:migration:base)").action((name, opts) => runMakeMigration("base", name, opts));
|
|
955
|
+
program.command("make:migration:base <name>").option("-c, --config <path>", "config file", "tenora.config.js").option("--esm", "force ESM template output").option("--cjs", "force CommonJS template output").description("Create a base migration file").action((name, opts) => runMakeMigration("base", name, opts));
|
|
956
|
+
program.command("make:migration:tenants <name>").option("-c, --config <path>", "config file", "tenora.config.js").option("--esm", "force ESM template output").option("--cjs", "force CommonJS template output").description("Create a tenant migration file").action((name, opts) => runMakeMigration("tenants", name, opts));
|
|
957
|
+
program.command("make:seed <name>").option("-c, --config <path>", "config file", "tenora.config.js").option("--esm", "force ESM template output").option("--cjs", "force CommonJS template output").description("Create a base seed file (alias of make:seed:base)").action((name, opts) => runMakeSeed("base", name, opts));
|
|
958
|
+
program.command("make:seed:base <name>").option("-c, --config <path>", "config file", "tenora.config.js").option("--esm", "force ESM template output").option("--cjs", "force CommonJS template output").description("Create a base seed file").action((name, opts) => runMakeSeed("base", name, opts));
|
|
959
|
+
program.command("make:seed:tenants <name>").option("-c, --config <path>", "config file", "tenora.config.js").option("--esm", "force ESM template output").option("--cjs", "force CommonJS template output").description("Create a tenant seed file").action((name, opts) => runMakeSeed("tenants", name, opts));
|
|
960
|
+
program.command("seed:run:base").option("-c, --config <path>", "config file", "tenora.config.js").option("--create-base", "create base database if missing").description("Run base database seeds").action(async (opts) => {
|
|
961
|
+
const cfg = await loadConfig(opts.config);
|
|
962
|
+
if (opts.createBase) {
|
|
963
|
+
await ensureBaseDatabase(cfg);
|
|
964
|
+
}
|
|
965
|
+
const manager = createTenoraFactory(cfg);
|
|
966
|
+
try {
|
|
967
|
+
const base = manager.getBase();
|
|
968
|
+
const result = await base.seed.run();
|
|
969
|
+
const files = Array.isArray(result) ? result[0] : [];
|
|
970
|
+
console.log(files.length ? files.join("\n") : "No seeds executed");
|
|
971
|
+
} finally {
|
|
972
|
+
await manager.destroyAll();
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
program.command("seed:run").option("-c, --config <path>", "config file", "tenora.config.js").option("--create-base", "create base database if missing").description("Run base database seeds (alias of seed:run:base)").action(async (opts) => {
|
|
976
|
+
const cfg = await loadConfig(opts.config);
|
|
977
|
+
if (opts.createBase) {
|
|
978
|
+
await ensureBaseDatabase(cfg);
|
|
979
|
+
}
|
|
980
|
+
const manager = createTenoraFactory(cfg);
|
|
981
|
+
try {
|
|
982
|
+
const base = manager.getBase();
|
|
983
|
+
const result = await base.seed.run();
|
|
984
|
+
const files = Array.isArray(result) ? result[0] : [];
|
|
985
|
+
console.log(files.length ? files.join("\n") : "No seeds executed");
|
|
986
|
+
} finally {
|
|
987
|
+
await manager.destroyAll();
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
program.command("seed:run:tenants").option("-c, --config <path>", "config file", "tenora.config.js").option("--create-base", "create base database if missing").description("Run tenant database seeds for all tenants").action(async (opts) => {
|
|
991
|
+
const cfg = await loadConfig(opts.config);
|
|
992
|
+
if (opts.createBase) {
|
|
993
|
+
await ensureBaseDatabase(cfg);
|
|
994
|
+
}
|
|
995
|
+
if (ensureRegistryMigrationIfNeeded(cfg)) return;
|
|
996
|
+
const manager = createTenoraFactory(cfg);
|
|
997
|
+
try {
|
|
998
|
+
const tenants = await listTenantsFromRegistry(manager.getBase(), cfg);
|
|
999
|
+
for (const tenant of tenants) {
|
|
1000
|
+
const pwd = getTenantPassword(tenant, cfg.decryptPassword, cfg);
|
|
1001
|
+
const knex3 = manager.getTenant(tenant.id, pwd);
|
|
1002
|
+
const result = await knex3.seed.run();
|
|
1003
|
+
const files = Array.isArray(result) ? result[0] : [];
|
|
1004
|
+
console.log(`Tenant ${tenant.id}: ${files.length ? files.join(", ") : "No seeds executed"}`);
|
|
1005
|
+
await knex3.destroy();
|
|
1006
|
+
}
|
|
1007
|
+
} finally {
|
|
1008
|
+
await manager.destroyAll();
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
program.command("list").description("List available commands").action(() => program.outputHelp());
|
|
1012
|
+
program.parse(process.argv);
|
|
1013
|
+
//# sourceMappingURL=cli.js.map
|