@nmakarov/cli-toolkit 0.32.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.cjs CHANGED
@@ -1129,8 +1129,13 @@ __export(src_exports, {
1129
1129
  enqueueTask: () => enqueueTask,
1130
1130
  ensureDeployKeyOnRemote: () => ensureDeployKeyOnRemote,
1131
1131
  ensureEnvOnRemote: () => ensureEnvOnRemote,
1132
+ ensureExtension: () => ensureExtension,
1133
+ ensureIndex: () => ensureIndex,
1132
1134
  ensureRemoteRepo: () => ensureRemoteRepo,
1133
1135
  ensureRepoDependencies: () => ensureRepoDependencies,
1136
+ ensureSchema: () => ensureSchema,
1137
+ ensureSchemaEverywhere: () => ensureSchemaEverywhere,
1138
+ ensureTable: () => ensureTable,
1134
1139
  ensureTaskTables: () => ensureTaskTables,
1135
1140
  flushTaskIpcLogs: () => flushTaskIpcLogs,
1136
1141
  getArgsInstance: () => getArgsInstance,
@@ -1169,6 +1174,7 @@ __export(src_exports, {
1169
1174
  releaseDir: () => releaseDir,
1170
1175
  releaseStamp: () => releaseStamp,
1171
1176
  reloadPm2: () => reloadPm2,
1177
+ replaceDatabaseName: () => replaceDatabaseName,
1172
1178
  resolveAsterisks: () => resolveAsterisks,
1173
1179
  resolveIpcFileLogsDir: () => resolveIpcFileLogsDir,
1174
1180
  resolveNextVersion: () => resolveNextVersion,
@@ -1966,6 +1972,43 @@ var Params = class _Params {
1966
1972
  module: moduleName ?? this._currentModule
1967
1973
  });
1968
1974
  }
1975
+ /**
1976
+ * Report a param value that a component RESOLVED ON ITS OWN — outside
1977
+ * Params.get() — e.g. discovered by combining its config files (the way
1978
+ * blueprints merge defaults/aggregator/feed data). Without this, every
1979
+ * key such a component probed for an override shows up in the
1980
+ * --showUsedParams dump as "undefined (default)"; reporting upgrades the
1981
+ * entry to the value the component actually works with.
1982
+ *
1983
+ * Attribution rules:
1984
+ * - entries figured from an explicit input (cli/env/options/…) are left
1985
+ * untouched — the component merely confirmed them, the origin stands;
1986
+ * - entries whose source is "default" (Params had nothing) are upgraded
1987
+ * in place to the reported value and source;
1988
+ * - keys never seen by Params are appended as new entries.
1989
+ * First report wins: once upgraded, later reports for the same key/module
1990
+ * are ignored (the source is no longer "default").
1991
+ *
1992
+ * @param {string} key
1993
+ * @param {*} value - the value the component actually uses
1994
+ * @param {string} [source="discovered"] - short origin label, e.g. "blueprint"
1995
+ * @param {string} [moduleName] - dump section; defaults to the current module
1996
+ */
1997
+ reportResolved(key, value, source = "discovered", moduleName) {
1998
+ const mod = moduleName ?? this._currentModule;
1999
+ const mine = this.trackedParams.filter((e) => e.key === key && e.module === mod);
2000
+ if (mine.some((e) => e.source !== "default")) {
2001
+ return;
2002
+ }
2003
+ if (mine.length === 0) {
2004
+ this.trackParam(key, "reported", value, source, mod);
2005
+ return;
2006
+ }
2007
+ for (const entry of mine) {
2008
+ entry.value = value;
2009
+ entry.source = source;
2010
+ }
2011
+ }
1969
2012
  /**
1970
2013
  * Get all tracked parameters (for --stopAfter=init)
1971
2014
  */
@@ -3471,13 +3514,113 @@ function listSources(basePath) {
3471
3514
 
3472
3515
  // src/db/index.js
3473
3516
  var import_knex = __toESM(require("knex"), 1);
3517
+
3518
+ // src/db/ensure.js
3519
+ var dbLabel = (db) => db?.config?.name ?? "db";
3520
+ async function ensureExtension(db, name, options = {}) {
3521
+ const action = `CREATE EXTENSION IF NOT EXISTS "${name}"`;
3522
+ if (!options.dryRun) {
3523
+ await db.raw(action);
3524
+ }
3525
+ options.logger?.silly?.(`[ensure] ${dbLabel(db)}: ${action}${options.dryRun ? " (dryRun)" : ""}`);
3526
+ return { action };
3527
+ }
3528
+ async function ensureTable(db, tableName, spec, options = {}) {
3529
+ const { dryRun = false, logger } = options;
3530
+ const actions = [];
3531
+ const exists = await db.tableExists(tableName);
3532
+ if (!exists) {
3533
+ actions.push(`CREATE TABLE ${tableName} (${Object.keys(spec.columns).length} columns)`);
3534
+ if (!dryRun) {
3535
+ await db.schema.createTable(tableName, (t) => {
3536
+ for (const define of Object.values(spec.columns)) {
3537
+ define(t, db);
3538
+ }
3539
+ });
3540
+ }
3541
+ } else {
3542
+ const missing = [];
3543
+ for (const column of Object.keys(spec.columns)) {
3544
+ if (!await db.schema.hasColumn(tableName, column)) {
3545
+ missing.push(column);
3546
+ }
3547
+ }
3548
+ if (missing.length > 0) {
3549
+ actions.push(`ALTER TABLE ${tableName} ADD COLUMN ${missing.join(", ")}`);
3550
+ if (!dryRun) {
3551
+ await db.schema.alterTable(tableName, (t) => {
3552
+ for (const column of missing) {
3553
+ spec.columns[column](t, db);
3554
+ }
3555
+ });
3556
+ }
3557
+ }
3558
+ }
3559
+ for (const index of spec.indexes ?? []) {
3560
+ const indexActions = await ensureIndex(db, tableName, index, options);
3561
+ actions.push(...indexActions);
3562
+ }
3563
+ for (const action of actions) {
3564
+ logger?.silly?.(`[ensure] ${dbLabel(db)}: ${action}${dryRun ? " (dryRun)" : ""}`);
3565
+ }
3566
+ return actions;
3567
+ }
3568
+ async function ensureIndex(db, tableName, index, options = {}) {
3569
+ const { dryRun = false } = options;
3570
+ const kind = index.unique ? "UNIQUE INDEX" : "INDEX";
3571
+ const cols = index.columns.map((c) => `"${c}"`).join(", ");
3572
+ const isPg = String(db?.config?.connectionString ?? "").startsWith("postgresql");
3573
+ if (isPg) {
3574
+ const sql2 = `CREATE ${kind} IF NOT EXISTS "${index.name}" ON "${tableName}" (${cols})`;
3575
+ const { rows } = await db.raw(`SELECT 1 FROM pg_indexes WHERE indexname = ?`, [index.name]);
3576
+ if (rows.length > 0) return [];
3577
+ if (!dryRun) await db.raw(sql2);
3578
+ return [sql2];
3579
+ }
3580
+ const sql = `CREATE ${kind} ${index.name} ON ${tableName} (${cols})`;
3581
+ if (!dryRun) {
3582
+ try {
3583
+ await db.raw(sql);
3584
+ } catch (error) {
3585
+ if (!/already exists|duplicate/i.test(error?.message ?? "")) throw error;
3586
+ return [];
3587
+ }
3588
+ }
3589
+ return [sql];
3590
+ }
3591
+ async function ensureSchema(db, spec, options = {}) {
3592
+ const actions = [];
3593
+ for (const extension of spec.extensions ?? []) {
3594
+ const { action } = await ensureExtension(db, extension, options);
3595
+ if (options.dryRun) actions.push(action);
3596
+ }
3597
+ for (const [tableName, tableSpec] of Object.entries(spec.tables ?? {})) {
3598
+ actions.push(...await ensureTable(db, tableName, tableSpec, options));
3599
+ }
3600
+ return { database: dbLabel(db), actions };
3601
+ }
3602
+ async function ensureSchemaEverywhere(dbs, spec, options = {}) {
3603
+ const reports = [];
3604
+ for (const db of dbs) {
3605
+ const report = await ensureSchema(db, spec, options);
3606
+ if (report.actions.length > 0) {
3607
+ options.logger?.info?.(
3608
+ `[ensure] ${report.database}: ${options.dryRun ? "would apply" : "applied"} ${report.actions.length} DDL statement(s)`
3609
+ );
3610
+ }
3611
+ reports.push(report);
3612
+ }
3613
+ return reports;
3614
+ }
3615
+
3616
+ // src/db/index.js
3474
3617
  var KNEX_DEFAULTS = {
3475
3618
  testConnection: true,
3476
3619
  pool: { min: 2, max: 10 },
3477
3620
  acquireConnectionTimeout: 1e4,
3478
3621
  ssl: { rejectUnauthorized: false }
3479
3622
  };
3480
- var Db = class {
3623
+ var Db = class _Db {
3481
3624
  static async init(context, options = {}) {
3482
3625
  const buildConfig = async () => {
3483
3626
  const defs = {
@@ -3546,6 +3689,177 @@ var Db = class {
3546
3689
  const config2 = context?.params?.runWithModuleAsync ? await context.params.runWithModuleAsync("db", buildConfig) : await buildConfig();
3547
3690
  return dbConnect(context, config2);
3548
3691
  }
3692
+ /**
3693
+ * Initialize a SIBLING database handler: a database that lives on the same
3694
+ * server with the same credentials/options as an existing ("base") one, and
3695
+ * differs only by its database name. Typical use: a per-tenant / per-subject
3696
+ * database alongside a main database that keeps the shared tables.
3697
+ *
3698
+ * context.db = await Db.init(context); // main
3699
+ * const sub = await Db.initSibling(context, "src_bright"); // sibling
3700
+ *
3701
+ * LOCATION-AGNOSTIC BY DESIGN — the call gracefully falls back to the main
3702
+ * database, so callers can use it for all subject data without knowing what
3703
+ * has been migrated where:
3704
+ * - empty `siblingName` → the main handler (same as Db.init)
3705
+ * - sibling DB does not exist yet → the main handler (data not migrated;
3706
+ * it still lives in the main database)
3707
+ * Callers that must not fall back can check `handler === context.db`.
3708
+ *
3709
+ * Connection string resolution for an actual sibling, in order:
3710
+ * 1. Explicit override — param `dbConnectionStringSib<SiblingName>` (env
3711
+ * `DB_CONNECTION_STRING_SIB_<SIBLING_NAME>`, e.g. `src_bright` →
3712
+ * `DB_CONNECTION_STRING_SIB_SRC_BRIGHT`). Nobody needs this on day
3713
+ * one; it is the escape hatch for when a sibling later moves to its
3714
+ * own server — one env var, no code changes (the `SIB_` namespace
3715
+ * both overrides the connection and declares that the sibling
3716
+ * exists, so it never falls back to main).
3717
+ * 2. Derived — take the base connection string and swap the database
3718
+ * name (after confirming the database exists on that server). The
3719
+ * base is `options.baseDb` (a Db handler), then
3720
+ * `options.baseConnectionString`, then `context.db`.
3721
+ *
3722
+ * Handlers are cached per name on the context (`context.siblingDbs`), so
3723
+ * any number of components asking for the same sibling share one pool —
3724
+ * including the "falls back to main" answer, which is remembered for the
3725
+ * lifetime of the process (a mid-run migration is picked up on restart).
3726
+ * Disconnect is registered via `context.registerCleanup`, same as the
3727
+ * main handler.
3728
+ *
3729
+ * @param {object} context - context with params/logger (and usually .db)
3730
+ * @param {string} [siblingName] - the sibling's database name (e.g. "src_bright")
3731
+ * @param {object} [options] - { baseDb, baseConnectionString, dbProfile }
3732
+ * @returns {Promise<Db>} connected handler (same proxy shape as Db.init)
3733
+ */
3734
+ static async initSibling(context, siblingName, options = {}) {
3735
+ if (!siblingName) {
3736
+ return resolveMainHandler(context, options);
3737
+ }
3738
+ if (!/^[a-zA-Z0-9_]+$/.test(siblingName)) {
3739
+ throw new ParamError(
3740
+ `Db.initSibling: invalid sibling database name "${siblingName}" (letters, digits and _ only)`
3741
+ );
3742
+ }
3743
+ if (!context.siblingDbs) {
3744
+ context.siblingDbs = /* @__PURE__ */ new Map();
3745
+ }
3746
+ const cached = context.siblingDbs.get(siblingName);
3747
+ if (cached) {
3748
+ return cached;
3749
+ }
3750
+ const overrideParam = `dbConnectionStringSib${camelizeDbName(siblingName)}`;
3751
+ let connectionString = await context?.params?.get?.(overrideParam, "string");
3752
+ if (connectionString) {
3753
+ context.logger?.debug?.(
3754
+ `[Db] sibling "${siblingName}": using override param "${overrideParam}"`
3755
+ );
3756
+ } else {
3757
+ const baseHandle = options.baseDb ?? context.db;
3758
+ const base = baseHandle?.config?.connectionString ?? options.baseConnectionString;
3759
+ if (!base) {
3760
+ throw new ParamError(
3761
+ `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)})`
3762
+ );
3763
+ }
3764
+ const exists = await databaseExistsOnServer(baseHandle, base, siblingName);
3765
+ if (!exists) {
3766
+ context.logger?.debug?.(
3767
+ `[Db] sibling "${siblingName}" does not exist \u2014 falling back to the main database`
3768
+ );
3769
+ const main = await resolveMainHandler(context, options);
3770
+ context.siblingDbs.set(siblingName, main);
3771
+ return main;
3772
+ }
3773
+ connectionString = replaceDatabaseName(base, siblingName);
3774
+ context.logger?.debug?.(
3775
+ `[Db] sibling "${siblingName}": derived from base (${formatConnectionEndpoint(base) ?? "?"})`
3776
+ );
3777
+ }
3778
+ const config2 = {
3779
+ ...KNEX_DEFAULTS,
3780
+ connectionString,
3781
+ name: siblingName,
3782
+ profile: !!options.dbProfile,
3783
+ logger: context.logger
3784
+ };
3785
+ const handler = await dbConnect(context, config2);
3786
+ context.siblingDbs.set(siblingName, handler);
3787
+ return handler;
3788
+ }
3789
+ /**
3790
+ * Discover the sibling databases that are "currently in use", by name.
3791
+ * Two sources, merged (env wins on duplicates):
3792
+ *
3793
+ * 1. ENV-DECLARED — every `DB_CONNECTION_STRING_SIB_<NAME>` env var
3794
+ * (the dedicated `SIB_` namespace) whose decoded name matches. This
3795
+ * covers siblings that moved to their own server: the same var that
3796
+ * overrides the connection also *registers* the database, so `.env`
3797
+ * stays the single convenient list.
3798
+ * 2. SAME-SERVER SCAN — `SELECT datname FROM pg_database` on the base
3799
+ * server (PostgreSQL only), filtered the same way.
3800
+ *
3801
+ * The caller says what "matches": `{ prefix: "src_" }` or
3802
+ * `{ match: /^src_/ }` — the toolkit does not guess a naming convention.
3803
+ *
3804
+ * @param {object} context - needs context.db (or options.baseDb) for the server scan
3805
+ * @param {{ prefix?: string, match?: RegExp, baseDb?: object, env?: object }} options
3806
+ * @returns {Promise<Array<{ name: string, origin: "env"|"server" }>>} sorted by name
3807
+ */
3808
+ static async discoverSiblings(context, options = {}) {
3809
+ const { prefix, match, env = process.env } = options;
3810
+ if (!prefix && !match) {
3811
+ throw new ParamError(`Db.discoverSiblings: pass { prefix: "..." } or { match: /.../ }`);
3812
+ }
3813
+ const matches = match instanceof RegExp ? (n) => match.test(n) : (n) => n.startsWith(prefix);
3814
+ const found = /* @__PURE__ */ new Map();
3815
+ for (const key of Object.keys(env)) {
3816
+ const m = /^DB_CONNECTION_STRING_SIB_(.+)$/.exec(key);
3817
+ if (!m || !env[key]) continue;
3818
+ const name = m[1].toLowerCase();
3819
+ if (matches(name)) {
3820
+ found.set(name, "env");
3821
+ }
3822
+ }
3823
+ const base = options.baseDb ?? context?.db;
3824
+ if (base) {
3825
+ const connectionString = String(base.config?.connectionString ?? "");
3826
+ if (connectionString.startsWith("postgresql")) {
3827
+ const { rows } = await base.raw(
3828
+ "SELECT datname FROM pg_database WHERE datistemplate = false"
3829
+ );
3830
+ for (const { datname } of rows) {
3831
+ if (matches(datname) && !found.has(datname)) {
3832
+ found.set(datname, "server");
3833
+ }
3834
+ }
3835
+ } else {
3836
+ context?.logger?.warn?.(
3837
+ "[Db] discoverSiblings: server scan supported for PostgreSQL only; using env-declared siblings"
3838
+ );
3839
+ }
3840
+ }
3841
+ return [...found].map(([name, origin]) => ({ name, origin })).sort((a, b) => a.name.localeCompare(b.name));
3842
+ }
3843
+ /**
3844
+ * Discover + connect: one handler per active sibling (cached, pooled —
3845
+ * see initSibling). Pass `includeMain: true` to get `[context.db, ...]`,
3846
+ * which is the usual shape for "apply this DDL everywhere" loops:
3847
+ *
3848
+ * const dbs = await Db.initAllSiblings(context, { prefix: "src_", includeMain: true });
3849
+ * await ensureSchemaEverywhere(dbs, spec, { logger: context.logger });
3850
+ *
3851
+ * @param {object} context
3852
+ * @param {{ prefix?: string, match?: RegExp, includeMain?: boolean, baseDb?: object, env?: object }} options
3853
+ * @returns {Promise<Function[]>} connected handlers
3854
+ */
3855
+ static async initAllSiblings(context, options = {}) {
3856
+ const discovered = await _Db.discoverSiblings(context, options);
3857
+ const handlers = [];
3858
+ for (const { name } of discovered) {
3859
+ handlers.push(await _Db.initSibling(context, name, options));
3860
+ }
3861
+ return options.includeMain && context.db ? [context.db, ...handlers] : handlers;
3862
+ }
3549
3863
  constructor(config2) {
3550
3864
  if (!config2 || !config2.connectionString) {
3551
3865
  throw new ParamError("Db: connectionString is required");
@@ -3806,6 +4120,54 @@ var Db = class {
3806
4120
  function capitalizeFirstLetter(str) {
3807
4121
  return str.charAt(0).toUpperCase() + str.slice(1);
3808
4122
  }
4123
+ async function resolveMainHandler(context, options = {}) {
4124
+ if (options.baseDb) {
4125
+ return options.baseDb;
4126
+ }
4127
+ if (!context.db) {
4128
+ context.db = await Db.init(context);
4129
+ }
4130
+ return context.db;
4131
+ }
4132
+ async function databaseExistsOnServer(baseHandle, baseConnectionString, databaseName) {
4133
+ if (!String(baseConnectionString).startsWith("postgresql")) {
4134
+ return true;
4135
+ }
4136
+ const query = "SELECT 1 FROM pg_database WHERE datname = ?";
4137
+ if (baseHandle) {
4138
+ const { rows } = await baseHandle.raw(query, [databaseName]);
4139
+ return rows.length > 0;
4140
+ }
4141
+ const shortLived = (0, import_knex.default)({
4142
+ client: "pg",
4143
+ connection: { connectionString: baseConnectionString },
4144
+ pool: { min: 0, max: 1 }
4145
+ });
4146
+ try {
4147
+ const { rows } = await shortLived.raw(query, [databaseName]);
4148
+ return rows.length > 0;
4149
+ } finally {
4150
+ await shortLived.destroy();
4151
+ }
4152
+ }
4153
+ function camelizeDbName(name) {
4154
+ return name.split(/[_-]+/).filter(Boolean).map(capitalizeFirstLetter).join("");
4155
+ }
4156
+ function toEnvKey(key) {
4157
+ return key.replace(/([A-Z])/g, "_$1").toUpperCase();
4158
+ }
4159
+ function replaceDatabaseName(connectionString, databaseName) {
4160
+ let url;
4161
+ try {
4162
+ url = new URL(connectionString);
4163
+ } catch {
4164
+ throw new ParamError(
4165
+ `Db: cannot parse connection string to derive a sibling database from it`
4166
+ );
4167
+ }
4168
+ url.pathname = `/${databaseName}`;
4169
+ return url.toString();
4170
+ }
3809
4171
  function resolveDbDisplayName(dbName, connectionParam, argsEnv, mergedName) {
3810
4172
  if (dbName) {
3811
4173
  return dbName;
@@ -4173,6 +4535,7 @@ var Aws = class _Aws {
4173
4535
  secretAccessKey: config2.secretAccessKey,
4174
4536
  ...config2.sessionToken ? { sessionToken: config2.sessionToken } : {}
4175
4537
  } : void 0;
4538
+ this.hasExplicitCredentials = !!this._credentials;
4176
4539
  process.env.AWS_SDK_JS_NODE_VERSION_SUPPORT_WARNING_DISABLED ??= "true";
4177
4540
  this._clients = {};
4178
4541
  }
@@ -4195,6 +4558,66 @@ var Aws = class _Aws {
4195
4558
  getRegion() {
4196
4559
  return this.region;
4197
4560
  }
4561
+ // ── credentials ─────────────────────────────────────────────────────────────
4562
+ /**
4563
+ * Are any credentials available *before* hitting AWS? Returns
4564
+ * { ok, source } — explicit keys, or a resolvable default chain (profile/SSO/
4565
+ * instance role). { ok:false } means there's nothing to even try with.
4566
+ * Note: "ok" only means creds were *found*, not that AWS will accept them.
4567
+ */
4568
+ async checkCredentials() {
4569
+ if (this.hasExplicitCredentials) {
4570
+ return { ok: true, source: "explicit keys (env/.env/CLI)" };
4571
+ }
4572
+ try {
4573
+ const provider = this._sts().config.credentials;
4574
+ const resolved = typeof provider === "function" ? await provider() : provider;
4575
+ if (resolved?.accessKeyId) {
4576
+ return { ok: true, source: "default credential chain (profile/SSO/role)" };
4577
+ }
4578
+ } catch {
4579
+ }
4580
+ return { ok: false };
4581
+ }
4582
+ /** True for "bad/missing credentials" style errors (vs. real failures). */
4583
+ static isAuthError(err) {
4584
+ const name = err?.name || err?.Code || err?.__type || "";
4585
+ return [
4586
+ "CredentialsProviderError",
4587
+ "InvalidClientTokenId",
4588
+ "UnrecognizedClientException",
4589
+ "AuthFailure",
4590
+ "AccessDenied",
4591
+ "AccessDeniedException",
4592
+ "ExpiredToken",
4593
+ "ExpiredTokenException",
4594
+ "SignatureDoesNotMatch",
4595
+ "MissingAuthenticationToken"
4596
+ ].includes(name);
4597
+ }
4598
+ /** Short, precise instructions for getting AWS credentials into .env. */
4599
+ static credentialsHelp(region = DEFAULT_REGION) {
4600
+ return [
4601
+ "No usable AWS credentials were found (or AWS rejected them).",
4602
+ "",
4603
+ "Put a read-only access key in the project's .env:",
4604
+ "",
4605
+ " AWS_ACCESS_KEY_ID=AKIA...",
4606
+ " AWS_SECRET_ACCESS_KEY=...",
4607
+ ` AWS_REGION=${region} # optional (default ${DEFAULT_REGION})`,
4608
+ "",
4609
+ "Get a key from the AWS console (~2 min):",
4610
+ " 1. IAM \u2192 Users \u2192 create or pick a user (console sign-in not needed).",
4611
+ ' 2. Attach a policy \u2014 "ReadOnlyAccess" (AWS managed) is enough for discovery.',
4612
+ ' 3. The user \u2192 "Security credentials" \u2192 "Create access key" \u2192 "CLI".',
4613
+ " 4. Copy the Access key ID + Secret access key (the secret shows only once).",
4614
+ " 5. Paste both into .env, then re-run.",
4615
+ " Direct link: https://console.aws.amazon.com/iam/home#/users",
4616
+ "",
4617
+ "Prefer a named profile or an EC2 instance role? Re-run with AWS_PROFILE=<name>",
4618
+ "set (or on the instance) and credentials resolve automatically."
4619
+ ].join("\n");
4620
+ }
4198
4621
  // ── identity ──────────────────────────────────────────────────────────────
4199
4622
  /** { account, arn, userId } — confirm which account/identity the keys belong to. */
4200
4623
  async whoAmI() {
@@ -5834,29 +6257,90 @@ function queueToTableNames(queueName) {
5834
6257
  registryTable: `${queueName}_services_registry`
5835
6258
  };
5836
6259
  }
5837
- function defineTasksTable(t, db, tableNameForIndex) {
5838
- t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
5839
- t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
5840
- t.timestamp("started_at");
5841
- t.timestamp("completed_at");
5842
- t.integer("priority").notNullable().defaultTo(50);
5843
- t.text("schedule");
5844
- t.timestamp("next_run_at").defaultTo(null);
5845
- t.timestamp("past_due").defaultTo(null);
5846
- t.text("name").notNullable();
5847
- t.text("opid");
5848
- t.jsonb("params");
5849
- t.text("service_group");
5850
- t.integer("instance_number");
5851
- t.text("service_name");
5852
- t.text("server_name");
5853
- t.text("status").notNullable().defaultTo("idle");
5854
- t.timestamp("status_changed_at").defaultTo(null);
5855
- t.text("progress");
5856
- t.boolean("success");
5857
- t.jsonb("results");
5858
- t.index(["service_group", "status", "priority", "created_at"], `${tableNameForIndex}_claim_idx`);
5859
- t.index(["service_group", "name"], `${tableNameForIndex}_group_name_idx`);
6260
+ function tasksTableSpec(tableNameForIndex) {
6261
+ return {
6262
+ columns: {
6263
+ id: (t, db) => t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()")),
6264
+ created_at: (t, db) => t.timestamp("created_at").notNullable().defaultTo(db.fn.now()),
6265
+ started_at: (t) => t.timestamp("started_at"),
6266
+ completed_at: (t) => t.timestamp("completed_at"),
6267
+ /*
6268
+ * Priority: lower number = claimed first (see claimNextRunnableTask: ORDER BY priority ASC).
6269
+ * Suggested range 0–100: 0 = most urgent, 100 = least; default 50 for normal work.
6270
+ */
6271
+ priority: (t) => t.integer("priority").notNullable().defaultTo(50),
6272
+ schedule: (t) => t.text("schedule"),
6273
+ next_run_at: (t) => t.timestamp("next_run_at").defaultTo(null),
6274
+ past_due: (t) => t.timestamp("past_due").defaultTo(null),
6275
+ name: (t) => t.text("name").notNullable(),
6276
+ opid: (t) => t.text("opid"),
6277
+ params: (t) => t.jsonb("params"),
6278
+ // those are target identifiers, kind of who is going to run a task.
6279
+ service_group: (t) => t.text("service_group"),
6280
+ // harvester, loader, photos, ...
6281
+ instance_number: (t) => t.integer("instance_number"),
6282
+ service_name: (t) => t.text("service_name"),
6283
+ // that's a "<server_name>_<service_group>_<instance_number>"
6284
+ server_name: (t) => t.text("server_name"),
6285
+ // filled by runner when registering, auto.
6286
+ status: (t) => t.text("status").notNullable().defaultTo("idle"),
6287
+ // idle, running, completed, failed, paused
6288
+ status_changed_at: (t) => t.timestamp("status_changed_at").defaultTo(null),
6289
+ progress: (t) => t.text("progress"),
6290
+ success: (t) => t.boolean("success"),
6291
+ results: (t) => t.jsonb("results")
6292
+ },
6293
+ indexes: [
6294
+ {
6295
+ columns: ["service_group", "status", "priority", "created_at"],
6296
+ name: `${tableNameForIndex}_claim_idx`
6297
+ },
6298
+ { columns: ["service_group", "name"], name: `${tableNameForIndex}_group_name_idx` }
6299
+ ]
6300
+ };
6301
+ }
6302
+ function registryTableSpec(registryTable) {
6303
+ return {
6304
+ columns: {
6305
+ id: (t, db) => t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()")),
6306
+ queue_name: (t) => t.text("queue_name").notNullable(),
6307
+ service_group: (t) => t.text("service_group").notNullable(),
6308
+ // harvester, loader, photos, ...
6309
+ instance_number: (t) => t.integer("instance_number").notNullable().defaultTo(1),
6310
+ service_name: (t) => t.text("service_name").notNullable(),
6311
+ // that's a "<server_name>_<service_group>_<instance_number>"
6312
+ server_name: (t) => t.text("server_name").notNullable(),
6313
+ // filled by runner when registering, auto.
6314
+ pid: (t) => t.integer("pid"),
6315
+ metadata: (t) => t.json("metadata"),
6316
+ created_at: (t, db) => t.timestamp("created_at").notNullable().defaultTo(db.fn.now()),
6317
+ last_seen_at: (t, db) => t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now())
6318
+ },
6319
+ indexes: [
6320
+ {
6321
+ columns: ["queue_name", "service_name"],
6322
+ name: `${registryTable}_queue_name_service_name_uniq`,
6323
+ unique: true
6324
+ },
6325
+ {
6326
+ columns: ["queue_name", "service_group", "last_seen_at"],
6327
+ name: `${registryTable}_queue_group_seen_idx`
6328
+ },
6329
+ { columns: ["queue_name", "last_seen_at"], name: `${registryTable}_queue_seen_idx` }
6330
+ ]
6331
+ // 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.
6332
+ };
6333
+ }
6334
+ function tasksSchemaSpec(queueName = "tasks") {
6335
+ const { tasksTable, historyTable, registryTable } = queueToTableNames(queueName);
6336
+ return {
6337
+ extensions: ["uuid-ossp"],
6338
+ tables: {
6339
+ [tasksTable]: tasksTableSpec(tasksTable),
6340
+ [historyTable]: tasksTableSpec(historyTable),
6341
+ [registryTable]: registryTableSpec(registryTable)
6342
+ }
6343
+ };
5860
6344
  }
5861
6345
  function taskHistoryInsertFromQueueRow(row, overrides) {
5862
6346
  const { id, ...snapshot } = row;
@@ -5870,60 +6354,38 @@ async function ensureTaskTables(context, options = {}) {
5870
6354
  const queueName = options.queueName ?? "tasks";
5871
6355
  const recreate = options.recreate ?? false;
5872
6356
  const dryRun = options.dryRun ?? false;
5873
- const db = getDb(context);
6357
+ const databases = options.databases ?? [getDb(context)];
5874
6358
  const log = context.logger ?? console;
5875
6359
  const { tasksTable, historyTable, registryTable } = queueToTableNames(queueName);
5876
- const needsTasks = recreate ? true : !await db.tableExists(tasksTable);
5877
- const needsHistory = recreate ? true : !await db.tableExists(historyTable);
5878
- const needsRegistry = recreate ? true : !await db.tableExists(registryTable);
5879
- if (dryRun) {
5880
- const plan = [];
6360
+ const spec = tasksSchemaSpec(queueName);
6361
+ for (const db of databases) {
6362
+ const label = db?.config?.name ?? "db";
5881
6363
  if (recreate) {
5882
- plan.push(`DROP TABLE IF EXISTS ${historyTable}, ${tasksTable}, ${registryTable}`);
6364
+ if (dryRun) {
6365
+ log.info?.(
6366
+ `[tasks-schema] dryRun \u2014 ${label}: DROP TABLE IF EXISTS ${historyTable}, ${tasksTable}, ${registryTable}`
6367
+ );
6368
+ } else {
6369
+ await db.schema.dropTableIfExists(historyTable);
6370
+ await db.schema.dropTableIfExists(tasksTable);
6371
+ await db.schema.dropTableIfExists(registryTable);
6372
+ }
5883
6373
  }
5884
- if (needsTasks) plan.push(`CREATE TABLE ${tasksTable} (tasks queue)`);
5885
- if (needsHistory) plan.push(`CREATE TABLE ${historyTable} (history mirror)`);
5886
- if (needsRegistry) plan.push(`CREATE TABLE ${registryTable} (services registry)`);
5887
- if (plan.length === 0) {
5888
- log.info?.(`[tasks-schema] dryRun \u2014 queue "${queueName}" already up to date; no DDL`);
5889
- } else {
5890
- log.info?.(`[tasks-schema] dryRun \u2014 would run ${plan.length} statement(s) for queue "${queueName}":`);
5891
- for (const s of plan) log.info?.(` - ${s}`);
6374
+ const { actions } = await ensureSchema(db, spec, { dryRun, logger: context.logger });
6375
+ if (dryRun) {
6376
+ if (actions.length === 0) {
6377
+ log.info?.(`[tasks-schema] dryRun \u2014 ${label}: queue "${queueName}" already up to date; no DDL`);
6378
+ } else {
6379
+ log.info?.(
6380
+ `[tasks-schema] dryRun \u2014 ${label}: would run ${actions.length} statement(s) for queue "${queueName}":`
6381
+ );
6382
+ for (const s of actions) log.info?.(` - ${s}`);
6383
+ }
6384
+ } else if (actions.length > 0) {
6385
+ log.info?.(
6386
+ `[tasks-schema] ${label}: applied ${actions.length} DDL statement(s) for queue "${queueName}"`
6387
+ );
5892
6388
  }
5893
- return;
5894
- }
5895
- if (recreate) {
5896
- await db.schema.dropTableIfExists(historyTable);
5897
- await db.schema.dropTableIfExists(tasksTable);
5898
- await db.schema.dropTableIfExists(registryTable);
5899
- }
5900
- if (needsTasks) {
5901
- await db.raw(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
5902
- await db.schema.createTable(tasksTable, (t) => {
5903
- defineTasksTable(t, db, tasksTable);
5904
- });
5905
- }
5906
- if (needsHistory) {
5907
- await db.schema.createTable(historyTable, (t) => {
5908
- defineTasksTable(t, db, historyTable);
5909
- });
5910
- }
5911
- if (needsRegistry) {
5912
- await db.schema.createTable(registryTable, (t) => {
5913
- t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
5914
- t.text("queue_name").notNullable();
5915
- t.text("service_group").notNullable();
5916
- t.integer("instance_number").notNullable().defaultTo(1);
5917
- t.text("service_name").notNullable();
5918
- t.text("server_name").notNullable();
5919
- t.integer("pid");
5920
- t.json("metadata");
5921
- t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
5922
- t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now());
5923
- t.unique(["queue_name", "service_name"], `${registryTable}_queue_name_service_name_uniq`);
5924
- t.index(["queue_name", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
5925
- t.index(["queue_name", "last_seen_at"], `${registryTable}_queue_seen_idx`);
5926
- });
5927
6389
  }
5928
6390
  }
5929
6391
  async function enqueueTask(context, options) {
@@ -8083,8 +8545,13 @@ var TasksManager = class _TasksManager {
8083
8545
  enqueueTask,
8084
8546
  ensureDeployKeyOnRemote,
8085
8547
  ensureEnvOnRemote,
8548
+ ensureExtension,
8549
+ ensureIndex,
8086
8550
  ensureRemoteRepo,
8087
8551
  ensureRepoDependencies,
8552
+ ensureSchema,
8553
+ ensureSchemaEverywhere,
8554
+ ensureTable,
8088
8555
  ensureTaskTables,
8089
8556
  flushTaskIpcLogs,
8090
8557
  getArgsInstance,
@@ -8123,6 +8590,7 @@ var TasksManager = class _TasksManager {
8123
8590
  releaseDir,
8124
8591
  releaseStamp,
8125
8592
  reloadPm2,
8593
+ replaceDatabaseName,
8126
8594
  resolveAsterisks,
8127
8595
  resolveIpcFileLogsDir,
8128
8596
  resolveNextVersion,