@nmakarov/cli-toolkit 0.33.0 → 0.36.0

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/index.js CHANGED
@@ -1799,6 +1799,43 @@ var Params = class _Params {
1799
1799
  module: moduleName ?? this._currentModule
1800
1800
  });
1801
1801
  }
1802
+ /**
1803
+ * Report a param value that a component RESOLVED ON ITS OWN — outside
1804
+ * Params.get() — e.g. discovered by combining its config files (the way
1805
+ * blueprints merge defaults/aggregator/feed data). Without this, every
1806
+ * key such a component probed for an override shows up in the
1807
+ * --showUsedParams dump as "undefined (default)"; reporting upgrades the
1808
+ * entry to the value the component actually works with.
1809
+ *
1810
+ * Attribution rules:
1811
+ * - entries figured from an explicit input (cli/env/options/…) are left
1812
+ * untouched — the component merely confirmed them, the origin stands;
1813
+ * - entries whose source is "default" (Params had nothing) are upgraded
1814
+ * in place to the reported value and source;
1815
+ * - keys never seen by Params are appended as new entries.
1816
+ * First report wins: once upgraded, later reports for the same key/module
1817
+ * are ignored (the source is no longer "default").
1818
+ *
1819
+ * @param {string} key
1820
+ * @param {*} value - the value the component actually uses
1821
+ * @param {string} [source="discovered"] - short origin label, e.g. "blueprint"
1822
+ * @param {string} [moduleName] - dump section; defaults to the current module
1823
+ */
1824
+ reportResolved(key, value, source = "discovered", moduleName) {
1825
+ const mod = moduleName ?? this._currentModule;
1826
+ const mine = this.trackedParams.filter((e) => e.key === key && e.module === mod);
1827
+ if (mine.some((e) => e.source !== "default")) {
1828
+ return;
1829
+ }
1830
+ if (mine.length === 0) {
1831
+ this.trackParam(key, "reported", value, source, mod);
1832
+ return;
1833
+ }
1834
+ for (const entry of mine) {
1835
+ entry.value = value;
1836
+ entry.source = source;
1837
+ }
1838
+ }
1802
1839
  /**
1803
1840
  * Get all tracked parameters (for --stopAfter=init)
1804
1841
  */
@@ -3304,13 +3341,113 @@ function listSources(basePath) {
3304
3341
 
3305
3342
  // src/db/index.js
3306
3343
  import knex from "knex";
3344
+
3345
+ // src/db/ensure.js
3346
+ var dbLabel = (db) => db?.config?.name ?? "db";
3347
+ async function ensureExtension(db, name, options = {}) {
3348
+ const action = `CREATE EXTENSION IF NOT EXISTS "${name}"`;
3349
+ if (!options.dryRun) {
3350
+ await db.raw(action);
3351
+ }
3352
+ options.logger?.silly?.(`[ensure] ${dbLabel(db)}: ${action}${options.dryRun ? " (dryRun)" : ""}`);
3353
+ return { action };
3354
+ }
3355
+ async function ensureTable(db, tableName, spec, options = {}) {
3356
+ const { dryRun = false, logger } = options;
3357
+ const actions = [];
3358
+ const exists = await db.tableExists(tableName);
3359
+ if (!exists) {
3360
+ actions.push(`CREATE TABLE ${tableName} (${Object.keys(spec.columns).length} columns)`);
3361
+ if (!dryRun) {
3362
+ await db.schema.createTable(tableName, (t) => {
3363
+ for (const define of Object.values(spec.columns)) {
3364
+ define(t, db);
3365
+ }
3366
+ });
3367
+ }
3368
+ } else {
3369
+ const missing = [];
3370
+ for (const column of Object.keys(spec.columns)) {
3371
+ if (!await db.schema.hasColumn(tableName, column)) {
3372
+ missing.push(column);
3373
+ }
3374
+ }
3375
+ if (missing.length > 0) {
3376
+ actions.push(`ALTER TABLE ${tableName} ADD COLUMN ${missing.join(", ")}`);
3377
+ if (!dryRun) {
3378
+ await db.schema.alterTable(tableName, (t) => {
3379
+ for (const column of missing) {
3380
+ spec.columns[column](t, db);
3381
+ }
3382
+ });
3383
+ }
3384
+ }
3385
+ }
3386
+ for (const index of spec.indexes ?? []) {
3387
+ const indexActions = await ensureIndex(db, tableName, index, options);
3388
+ actions.push(...indexActions);
3389
+ }
3390
+ for (const action of actions) {
3391
+ logger?.silly?.(`[ensure] ${dbLabel(db)}: ${action}${dryRun ? " (dryRun)" : ""}`);
3392
+ }
3393
+ return actions;
3394
+ }
3395
+ async function ensureIndex(db, tableName, index, options = {}) {
3396
+ const { dryRun = false } = options;
3397
+ const kind = index.unique ? "UNIQUE INDEX" : "INDEX";
3398
+ const cols = index.columns.map((c) => `"${c}"`).join(", ");
3399
+ const isPg = String(db?.config?.connectionString ?? "").startsWith("postgresql");
3400
+ if (isPg) {
3401
+ const sql2 = `CREATE ${kind} IF NOT EXISTS "${index.name}" ON "${tableName}" (${cols})`;
3402
+ const { rows } = await db.raw(`SELECT 1 FROM pg_indexes WHERE indexname = ?`, [index.name]);
3403
+ if (rows.length > 0) return [];
3404
+ if (!dryRun) await db.raw(sql2);
3405
+ return [sql2];
3406
+ }
3407
+ const sql = `CREATE ${kind} ${index.name} ON ${tableName} (${cols})`;
3408
+ if (!dryRun) {
3409
+ try {
3410
+ await db.raw(sql);
3411
+ } catch (error) {
3412
+ if (!/already exists|duplicate/i.test(error?.message ?? "")) throw error;
3413
+ return [];
3414
+ }
3415
+ }
3416
+ return [sql];
3417
+ }
3418
+ async function ensureSchema(db, spec, options = {}) {
3419
+ const actions = [];
3420
+ for (const extension of spec.extensions ?? []) {
3421
+ const { action } = await ensureExtension(db, extension, options);
3422
+ if (options.dryRun) actions.push(action);
3423
+ }
3424
+ for (const [tableName, tableSpec] of Object.entries(spec.tables ?? {})) {
3425
+ actions.push(...await ensureTable(db, tableName, tableSpec, options));
3426
+ }
3427
+ return { database: dbLabel(db), actions };
3428
+ }
3429
+ async function ensureSchemaEverywhere(dbs, spec, options = {}) {
3430
+ const reports = [];
3431
+ for (const db of dbs) {
3432
+ const report = await ensureSchema(db, spec, options);
3433
+ if (report.actions.length > 0) {
3434
+ options.logger?.info?.(
3435
+ `[ensure] ${report.database}: ${options.dryRun ? "would apply" : "applied"} ${report.actions.length} DDL statement(s)`
3436
+ );
3437
+ }
3438
+ reports.push(report);
3439
+ }
3440
+ return reports;
3441
+ }
3442
+
3443
+ // src/db/index.js
3307
3444
  var KNEX_DEFAULTS = {
3308
3445
  testConnection: true,
3309
3446
  pool: { min: 2, max: 10 },
3310
3447
  acquireConnectionTimeout: 1e4,
3311
3448
  ssl: { rejectUnauthorized: false }
3312
3449
  };
3313
- var Db = class {
3450
+ var Db = class _Db {
3314
3451
  static async init(context, options = {}) {
3315
3452
  const buildConfig = async () => {
3316
3453
  const defs = {
@@ -3379,6 +3516,177 @@ var Db = class {
3379
3516
  const config2 = context?.params?.runWithModuleAsync ? await context.params.runWithModuleAsync("db", buildConfig) : await buildConfig();
3380
3517
  return dbConnect(context, config2);
3381
3518
  }
3519
+ /**
3520
+ * Initialize a SIBLING database handler: a database that lives on the same
3521
+ * server with the same credentials/options as an existing ("base") one, and
3522
+ * differs only by its database name. Typical use: a per-tenant / per-subject
3523
+ * database alongside a main database that keeps the shared tables.
3524
+ *
3525
+ * context.db = await Db.init(context); // main
3526
+ * const sub = await Db.initSibling(context, "src_bright"); // sibling
3527
+ *
3528
+ * LOCATION-AGNOSTIC BY DESIGN — the call gracefully falls back to the main
3529
+ * database, so callers can use it for all subject data without knowing what
3530
+ * has been migrated where:
3531
+ * - empty `siblingName` → the main handler (same as Db.init)
3532
+ * - sibling DB does not exist yet → the main handler (data not migrated;
3533
+ * it still lives in the main database)
3534
+ * Callers that must not fall back can check `handler === context.db`.
3535
+ *
3536
+ * Connection string resolution for an actual sibling, in order:
3537
+ * 1. Explicit override — param `dbConnectionStringSib<SiblingName>` (env
3538
+ * `DB_CONNECTION_STRING_SIB_<SIBLING_NAME>`, e.g. `src_bright` →
3539
+ * `DB_CONNECTION_STRING_SIB_SRC_BRIGHT`). Nobody needs this on day
3540
+ * one; it is the escape hatch for when a sibling later moves to its
3541
+ * own server — one env var, no code changes (the `SIB_` namespace
3542
+ * both overrides the connection and declares that the sibling
3543
+ * exists, so it never falls back to main).
3544
+ * 2. Derived — take the base connection string and swap the database
3545
+ * name (after confirming the database exists on that server). The
3546
+ * base is `options.baseDb` (a Db handler), then
3547
+ * `options.baseConnectionString`, then `context.db`.
3548
+ *
3549
+ * Handlers are cached per name on the context (`context.siblingDbs`), so
3550
+ * any number of components asking for the same sibling share one pool —
3551
+ * including the "falls back to main" answer, which is remembered for the
3552
+ * lifetime of the process (a mid-run migration is picked up on restart).
3553
+ * Disconnect is registered via `context.registerCleanup`, same as the
3554
+ * main handler.
3555
+ *
3556
+ * @param {object} context - context with params/logger (and usually .db)
3557
+ * @param {string} [siblingName] - the sibling's database name (e.g. "src_bright")
3558
+ * @param {object} [options] - { baseDb, baseConnectionString, dbProfile }
3559
+ * @returns {Promise<Db>} connected handler (same proxy shape as Db.init)
3560
+ */
3561
+ static async initSibling(context, siblingName, options = {}) {
3562
+ if (!siblingName) {
3563
+ return resolveMainHandler(context, options);
3564
+ }
3565
+ if (!/^[a-zA-Z0-9_]+$/.test(siblingName)) {
3566
+ throw new ParamError(
3567
+ `Db.initSibling: invalid sibling database name "${siblingName}" (letters, digits and _ only)`
3568
+ );
3569
+ }
3570
+ if (!context.siblingDbs) {
3571
+ context.siblingDbs = /* @__PURE__ */ new Map();
3572
+ }
3573
+ const cached = context.siblingDbs.get(siblingName);
3574
+ if (cached) {
3575
+ return cached;
3576
+ }
3577
+ const overrideParam = `dbConnectionStringSib${camelizeDbName(siblingName)}`;
3578
+ let connectionString = await context?.params?.get?.(overrideParam, "string");
3579
+ if (connectionString) {
3580
+ context.logger?.debug?.(
3581
+ `[Db] sibling "${siblingName}": using override param "${overrideParam}"`
3582
+ );
3583
+ } else {
3584
+ const baseHandle = options.baseDb ?? context.db;
3585
+ const base = baseHandle?.config?.connectionString ?? options.baseConnectionString;
3586
+ if (!base) {
3587
+ throw new ParamError(
3588
+ `Db.initSibling: no base connection to derive "${siblingName}" from \u2014 init the main Db first (context.db = await Db.init(context)), or pass options.baseDb / options.baseConnectionString, or set the ${overrideParam} param (env ${toEnvKey(overrideParam)})`
3589
+ );
3590
+ }
3591
+ const exists = await databaseExistsOnServer(baseHandle, base, siblingName);
3592
+ if (!exists) {
3593
+ context.logger?.debug?.(
3594
+ `[Db] sibling "${siblingName}" does not exist \u2014 falling back to the main database`
3595
+ );
3596
+ const main = await resolveMainHandler(context, options);
3597
+ context.siblingDbs.set(siblingName, main);
3598
+ return main;
3599
+ }
3600
+ connectionString = replaceDatabaseName(base, siblingName);
3601
+ context.logger?.debug?.(
3602
+ `[Db] sibling "${siblingName}": derived from base (${formatConnectionEndpoint(base) ?? "?"})`
3603
+ );
3604
+ }
3605
+ const config2 = {
3606
+ ...KNEX_DEFAULTS,
3607
+ connectionString,
3608
+ name: siblingName,
3609
+ profile: !!options.dbProfile,
3610
+ logger: context.logger
3611
+ };
3612
+ const handler = await dbConnect(context, config2);
3613
+ context.siblingDbs.set(siblingName, handler);
3614
+ return handler;
3615
+ }
3616
+ /**
3617
+ * Discover the sibling databases that are "currently in use", by name.
3618
+ * Two sources, merged (env wins on duplicates):
3619
+ *
3620
+ * 1. ENV-DECLARED — every `DB_CONNECTION_STRING_SIB_<NAME>` env var
3621
+ * (the dedicated `SIB_` namespace) whose decoded name matches. This
3622
+ * covers siblings that moved to their own server: the same var that
3623
+ * overrides the connection also *registers* the database, so `.env`
3624
+ * stays the single convenient list.
3625
+ * 2. SAME-SERVER SCAN — `SELECT datname FROM pg_database` on the base
3626
+ * server (PostgreSQL only), filtered the same way.
3627
+ *
3628
+ * The caller says what "matches": `{ prefix: "src_" }` or
3629
+ * `{ match: /^src_/ }` — the toolkit does not guess a naming convention.
3630
+ *
3631
+ * @param {object} context - needs context.db (or options.baseDb) for the server scan
3632
+ * @param {{ prefix?: string, match?: RegExp, baseDb?: object, env?: object }} options
3633
+ * @returns {Promise<Array<{ name: string, origin: "env"|"server" }>>} sorted by name
3634
+ */
3635
+ static async discoverSiblings(context, options = {}) {
3636
+ const { prefix, match, env = process.env } = options;
3637
+ if (!prefix && !match) {
3638
+ throw new ParamError(`Db.discoverSiblings: pass { prefix: "..." } or { match: /.../ }`);
3639
+ }
3640
+ const matches = match instanceof RegExp ? (n) => match.test(n) : (n) => n.startsWith(prefix);
3641
+ const found = /* @__PURE__ */ new Map();
3642
+ for (const key of Object.keys(env)) {
3643
+ const m = /^DB_CONNECTION_STRING_SIB_(.+)$/.exec(key);
3644
+ if (!m || !env[key]) continue;
3645
+ const name = m[1].toLowerCase();
3646
+ if (matches(name)) {
3647
+ found.set(name, "env");
3648
+ }
3649
+ }
3650
+ const base = options.baseDb ?? context?.db;
3651
+ if (base) {
3652
+ const connectionString = String(base.config?.connectionString ?? "");
3653
+ if (connectionString.startsWith("postgresql")) {
3654
+ const { rows } = await base.raw(
3655
+ "SELECT datname FROM pg_database WHERE datistemplate = false"
3656
+ );
3657
+ for (const { datname } of rows) {
3658
+ if (matches(datname) && !found.has(datname)) {
3659
+ found.set(datname, "server");
3660
+ }
3661
+ }
3662
+ } else {
3663
+ context?.logger?.warn?.(
3664
+ "[Db] discoverSiblings: server scan supported for PostgreSQL only; using env-declared siblings"
3665
+ );
3666
+ }
3667
+ }
3668
+ return [...found].map(([name, origin]) => ({ name, origin })).sort((a, b) => a.name.localeCompare(b.name));
3669
+ }
3670
+ /**
3671
+ * Discover + connect: one handler per active sibling (cached, pooled —
3672
+ * see initSibling). Pass `includeMain: true` to get `[context.db, ...]`,
3673
+ * which is the usual shape for "apply this DDL everywhere" loops:
3674
+ *
3675
+ * const dbs = await Db.initAllSiblings(context, { prefix: "src_", includeMain: true });
3676
+ * await ensureSchemaEverywhere(dbs, spec, { logger: context.logger });
3677
+ *
3678
+ * @param {object} context
3679
+ * @param {{ prefix?: string, match?: RegExp, includeMain?: boolean, baseDb?: object, env?: object }} options
3680
+ * @returns {Promise<Function[]>} connected handlers
3681
+ */
3682
+ static async initAllSiblings(context, options = {}) {
3683
+ const discovered = await _Db.discoverSiblings(context, options);
3684
+ const handlers = [];
3685
+ for (const { name } of discovered) {
3686
+ handlers.push(await _Db.initSibling(context, name, options));
3687
+ }
3688
+ return options.includeMain && context.db ? [context.db, ...handlers] : handlers;
3689
+ }
3382
3690
  constructor(config2) {
3383
3691
  if (!config2 || !config2.connectionString) {
3384
3692
  throw new ParamError("Db: connectionString is required");
@@ -3639,6 +3947,54 @@ var Db = class {
3639
3947
  function capitalizeFirstLetter(str) {
3640
3948
  return str.charAt(0).toUpperCase() + str.slice(1);
3641
3949
  }
3950
+ async function resolveMainHandler(context, options = {}) {
3951
+ if (options.baseDb) {
3952
+ return options.baseDb;
3953
+ }
3954
+ if (!context.db) {
3955
+ context.db = await Db.init(context);
3956
+ }
3957
+ return context.db;
3958
+ }
3959
+ async function databaseExistsOnServer(baseHandle, baseConnectionString, databaseName) {
3960
+ if (!String(baseConnectionString).startsWith("postgresql")) {
3961
+ return true;
3962
+ }
3963
+ const query = "SELECT 1 FROM pg_database WHERE datname = ?";
3964
+ if (baseHandle) {
3965
+ const { rows } = await baseHandle.raw(query, [databaseName]);
3966
+ return rows.length > 0;
3967
+ }
3968
+ const shortLived = knex({
3969
+ client: "pg",
3970
+ connection: { connectionString: baseConnectionString },
3971
+ pool: { min: 0, max: 1 }
3972
+ });
3973
+ try {
3974
+ const { rows } = await shortLived.raw(query, [databaseName]);
3975
+ return rows.length > 0;
3976
+ } finally {
3977
+ await shortLived.destroy();
3978
+ }
3979
+ }
3980
+ function camelizeDbName(name) {
3981
+ return name.split(/[_-]+/).filter(Boolean).map(capitalizeFirstLetter).join("");
3982
+ }
3983
+ function toEnvKey(key) {
3984
+ return key.replace(/([A-Z])/g, "_$1").toUpperCase();
3985
+ }
3986
+ function replaceDatabaseName(connectionString, databaseName) {
3987
+ let url;
3988
+ try {
3989
+ url = new URL(connectionString);
3990
+ } catch {
3991
+ throw new ParamError(
3992
+ `Db: cannot parse connection string to derive a sibling database from it`
3993
+ );
3994
+ }
3995
+ url.pathname = `/${databaseName}`;
3996
+ return url.toString();
3997
+ }
3642
3998
  function resolveDbDisplayName(dbName, connectionParam, argsEnv, mergedName) {
3643
3999
  if (dbName) {
3644
4000
  return dbName;
@@ -5754,29 +6110,90 @@ function queueToTableNames(queueName) {
5754
6110
  registryTable: `${queueName}_services_registry`
5755
6111
  };
5756
6112
  }
5757
- function defineTasksTable(t, db, tableNameForIndex) {
5758
- t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
5759
- t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
5760
- t.timestamp("started_at");
5761
- t.timestamp("completed_at");
5762
- t.integer("priority").notNullable().defaultTo(50);
5763
- t.text("schedule");
5764
- t.timestamp("next_run_at").defaultTo(null);
5765
- t.timestamp("past_due").defaultTo(null);
5766
- t.text("name").notNullable();
5767
- t.text("opid");
5768
- t.jsonb("params");
5769
- t.text("service_group");
5770
- t.integer("instance_number");
5771
- t.text("service_name");
5772
- t.text("server_name");
5773
- t.text("status").notNullable().defaultTo("idle");
5774
- t.timestamp("status_changed_at").defaultTo(null);
5775
- t.text("progress");
5776
- t.boolean("success");
5777
- t.jsonb("results");
5778
- t.index(["service_group", "status", "priority", "created_at"], `${tableNameForIndex}_claim_idx`);
5779
- t.index(["service_group", "name"], `${tableNameForIndex}_group_name_idx`);
6113
+ function tasksTableSpec(tableNameForIndex) {
6114
+ return {
6115
+ columns: {
6116
+ id: (t, db) => t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()")),
6117
+ created_at: (t, db) => t.timestamp("created_at").notNullable().defaultTo(db.fn.now()),
6118
+ started_at: (t) => t.timestamp("started_at"),
6119
+ completed_at: (t) => t.timestamp("completed_at"),
6120
+ /*
6121
+ * Priority: lower number = claimed first (see claimNextRunnableTask: ORDER BY priority ASC).
6122
+ * Suggested range 0–100: 0 = most urgent, 100 = least; default 50 for normal work.
6123
+ */
6124
+ priority: (t) => t.integer("priority").notNullable().defaultTo(50),
6125
+ schedule: (t) => t.text("schedule"),
6126
+ next_run_at: (t) => t.timestamp("next_run_at").defaultTo(null),
6127
+ past_due: (t) => t.timestamp("past_due").defaultTo(null),
6128
+ name: (t) => t.text("name").notNullable(),
6129
+ opid: (t) => t.text("opid"),
6130
+ params: (t) => t.jsonb("params"),
6131
+ // those are target identifiers, kind of who is going to run a task.
6132
+ service_group: (t) => t.text("service_group"),
6133
+ // harvester, loader, photos, ...
6134
+ instance_number: (t) => t.integer("instance_number"),
6135
+ service_name: (t) => t.text("service_name"),
6136
+ // that's a "<server_name>_<service_group>_<instance_number>"
6137
+ server_name: (t) => t.text("server_name"),
6138
+ // filled by runner when registering, auto.
6139
+ status: (t) => t.text("status").notNullable().defaultTo("idle"),
6140
+ // idle, running, completed, failed, paused
6141
+ status_changed_at: (t) => t.timestamp("status_changed_at").defaultTo(null),
6142
+ progress: (t) => t.text("progress"),
6143
+ success: (t) => t.boolean("success"),
6144
+ results: (t) => t.jsonb("results")
6145
+ },
6146
+ indexes: [
6147
+ {
6148
+ columns: ["service_group", "status", "priority", "created_at"],
6149
+ name: `${tableNameForIndex}_claim_idx`
6150
+ },
6151
+ { columns: ["service_group", "name"], name: `${tableNameForIndex}_group_name_idx` }
6152
+ ]
6153
+ };
6154
+ }
6155
+ function registryTableSpec(registryTable) {
6156
+ return {
6157
+ columns: {
6158
+ id: (t, db) => t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()")),
6159
+ queue_name: (t) => t.text("queue_name").notNullable(),
6160
+ service_group: (t) => t.text("service_group").notNullable(),
6161
+ // harvester, loader, photos, ...
6162
+ instance_number: (t) => t.integer("instance_number").notNullable().defaultTo(1),
6163
+ service_name: (t) => t.text("service_name").notNullable(),
6164
+ // that's a "<server_name>_<service_group>_<instance_number>"
6165
+ server_name: (t) => t.text("server_name").notNullable(),
6166
+ // filled by runner when registering, auto.
6167
+ pid: (t) => t.integer("pid"),
6168
+ metadata: (t) => t.json("metadata"),
6169
+ created_at: (t, db) => t.timestamp("created_at").notNullable().defaultTo(db.fn.now()),
6170
+ last_seen_at: (t, db) => t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now())
6171
+ },
6172
+ indexes: [
6173
+ {
6174
+ columns: ["queue_name", "service_name"],
6175
+ name: `${registryTable}_queue_name_service_name_uniq`,
6176
+ unique: true
6177
+ },
6178
+ {
6179
+ columns: ["queue_name", "service_group", "last_seen_at"],
6180
+ name: `${registryTable}_queue_group_seen_idx`
6181
+ },
6182
+ { columns: ["queue_name", "last_seen_at"], name: `${registryTable}_queue_seen_idx` }
6183
+ ]
6184
+ // TODO: a reference is needed - task_history entry should reference the registry entry, so that we can easily find all executed tasks for a given service and calculate the average workload or identify if there's a bottleneck. Also may be used for the load balancing.
6185
+ };
6186
+ }
6187
+ function tasksSchemaSpec(queueName = "tasks") {
6188
+ const { tasksTable, historyTable, registryTable } = queueToTableNames(queueName);
6189
+ return {
6190
+ extensions: ["uuid-ossp"],
6191
+ tables: {
6192
+ [tasksTable]: tasksTableSpec(tasksTable),
6193
+ [historyTable]: tasksTableSpec(historyTable),
6194
+ [registryTable]: registryTableSpec(registryTable)
6195
+ }
6196
+ };
5780
6197
  }
5781
6198
  function taskHistoryInsertFromQueueRow(row, overrides) {
5782
6199
  const { id, ...snapshot } = row;
@@ -5790,60 +6207,38 @@ async function ensureTaskTables(context, options = {}) {
5790
6207
  const queueName = options.queueName ?? "tasks";
5791
6208
  const recreate = options.recreate ?? false;
5792
6209
  const dryRun = options.dryRun ?? false;
5793
- const db = getDb(context);
6210
+ const databases = options.databases ?? [getDb(context)];
5794
6211
  const log = context.logger ?? console;
5795
6212
  const { tasksTable, historyTable, registryTable } = queueToTableNames(queueName);
5796
- const needsTasks = recreate ? true : !await db.tableExists(tasksTable);
5797
- const needsHistory = recreate ? true : !await db.tableExists(historyTable);
5798
- const needsRegistry = recreate ? true : !await db.tableExists(registryTable);
5799
- if (dryRun) {
5800
- const plan = [];
6213
+ const spec = tasksSchemaSpec(queueName);
6214
+ for (const db of databases) {
6215
+ const label = db?.config?.name ?? "db";
5801
6216
  if (recreate) {
5802
- plan.push(`DROP TABLE IF EXISTS ${historyTable}, ${tasksTable}, ${registryTable}`);
6217
+ if (dryRun) {
6218
+ log.info?.(
6219
+ `[tasks-schema] dryRun \u2014 ${label}: DROP TABLE IF EXISTS ${historyTable}, ${tasksTable}, ${registryTable}`
6220
+ );
6221
+ } else {
6222
+ await db.schema.dropTableIfExists(historyTable);
6223
+ await db.schema.dropTableIfExists(tasksTable);
6224
+ await db.schema.dropTableIfExists(registryTable);
6225
+ }
5803
6226
  }
5804
- if (needsTasks) plan.push(`CREATE TABLE ${tasksTable} (tasks queue)`);
5805
- if (needsHistory) plan.push(`CREATE TABLE ${historyTable} (history mirror)`);
5806
- if (needsRegistry) plan.push(`CREATE TABLE ${registryTable} (services registry)`);
5807
- if (plan.length === 0) {
5808
- log.info?.(`[tasks-schema] dryRun \u2014 queue "${queueName}" already up to date; no DDL`);
5809
- } else {
5810
- log.info?.(`[tasks-schema] dryRun \u2014 would run ${plan.length} statement(s) for queue "${queueName}":`);
5811
- for (const s of plan) log.info?.(` - ${s}`);
6227
+ const { actions } = await ensureSchema(db, spec, { dryRun, logger: context.logger });
6228
+ if (dryRun) {
6229
+ if (actions.length === 0) {
6230
+ log.info?.(`[tasks-schema] dryRun \u2014 ${label}: queue "${queueName}" already up to date; no DDL`);
6231
+ } else {
6232
+ log.info?.(
6233
+ `[tasks-schema] dryRun \u2014 ${label}: would run ${actions.length} statement(s) for queue "${queueName}":`
6234
+ );
6235
+ for (const s of actions) log.info?.(` - ${s}`);
6236
+ }
6237
+ } else if (actions.length > 0) {
6238
+ log.info?.(
6239
+ `[tasks-schema] ${label}: applied ${actions.length} DDL statement(s) for queue "${queueName}"`
6240
+ );
5812
6241
  }
5813
- return;
5814
- }
5815
- if (recreate) {
5816
- await db.schema.dropTableIfExists(historyTable);
5817
- await db.schema.dropTableIfExists(tasksTable);
5818
- await db.schema.dropTableIfExists(registryTable);
5819
- }
5820
- if (needsTasks) {
5821
- await db.raw(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
5822
- await db.schema.createTable(tasksTable, (t) => {
5823
- defineTasksTable(t, db, tasksTable);
5824
- });
5825
- }
5826
- if (needsHistory) {
5827
- await db.schema.createTable(historyTable, (t) => {
5828
- defineTasksTable(t, db, historyTable);
5829
- });
5830
- }
5831
- if (needsRegistry) {
5832
- await db.schema.createTable(registryTable, (t) => {
5833
- t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
5834
- t.text("queue_name").notNullable();
5835
- t.text("service_group").notNullable();
5836
- t.integer("instance_number").notNullable().defaultTo(1);
5837
- t.text("service_name").notNullable();
5838
- t.text("server_name").notNullable();
5839
- t.integer("pid");
5840
- t.json("metadata");
5841
- t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
5842
- t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now());
5843
- t.unique(["queue_name", "service_name"], `${registryTable}_queue_name_service_name_uniq`);
5844
- t.index(["queue_name", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
5845
- t.index(["queue_name", "last_seen_at"], `${registryTable}_queue_seen_idx`);
5846
- });
5847
6242
  }
5848
6243
  }
5849
6244
  async function enqueueTask(context, options) {
@@ -8002,8 +8397,13 @@ export {
8002
8397
  enqueueTask,
8003
8398
  ensureDeployKeyOnRemote,
8004
8399
  ensureEnvOnRemote,
8400
+ ensureExtension,
8401
+ ensureIndex,
8005
8402
  ensureRemoteRepo,
8006
8403
  ensureRepoDependencies,
8404
+ ensureSchema,
8405
+ ensureSchemaEverywhere,
8406
+ ensureTable,
8007
8407
  ensureTaskTables,
8008
8408
  flushTaskIpcLogs,
8009
8409
  getArgsInstance,
@@ -8042,6 +8442,7 @@ export {
8042
8442
  releaseDir,
8043
8443
  releaseStamp,
8044
8444
  reloadPm2,
8445
+ replaceDatabaseName,
8045
8446
  resolveAsterisks,
8046
8447
  resolveIpcFileLogsDir,
8047
8448
  resolveNextVersion,