@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/tasks.cjs CHANGED
@@ -164,6 +164,91 @@ var import_node_os = __toESM(require("os"), 1);
164
164
  // src/tasks/taskUtils.js
165
165
  var import_node_crypto = require("crypto");
166
166
 
167
+ // src/db/ensure.js
168
+ var dbLabel = (db) => db?.config?.name ?? "db";
169
+ async function ensureExtension(db, name, options = {}) {
170
+ const action = `CREATE EXTENSION IF NOT EXISTS "${name}"`;
171
+ if (!options.dryRun) {
172
+ await db.raw(action);
173
+ }
174
+ options.logger?.silly?.(`[ensure] ${dbLabel(db)}: ${action}${options.dryRun ? " (dryRun)" : ""}`);
175
+ return { action };
176
+ }
177
+ async function ensureTable(db, tableName, spec, options = {}) {
178
+ const { dryRun = false, logger } = options;
179
+ const actions = [];
180
+ const exists = await db.tableExists(tableName);
181
+ if (!exists) {
182
+ actions.push(`CREATE TABLE ${tableName} (${Object.keys(spec.columns).length} columns)`);
183
+ if (!dryRun) {
184
+ await db.schema.createTable(tableName, (t) => {
185
+ for (const define of Object.values(spec.columns)) {
186
+ define(t, db);
187
+ }
188
+ });
189
+ }
190
+ } else {
191
+ const missing = [];
192
+ for (const column of Object.keys(spec.columns)) {
193
+ if (!await db.schema.hasColumn(tableName, column)) {
194
+ missing.push(column);
195
+ }
196
+ }
197
+ if (missing.length > 0) {
198
+ actions.push(`ALTER TABLE ${tableName} ADD COLUMN ${missing.join(", ")}`);
199
+ if (!dryRun) {
200
+ await db.schema.alterTable(tableName, (t) => {
201
+ for (const column of missing) {
202
+ spec.columns[column](t, db);
203
+ }
204
+ });
205
+ }
206
+ }
207
+ }
208
+ for (const index of spec.indexes ?? []) {
209
+ const indexActions = await ensureIndex(db, tableName, index, options);
210
+ actions.push(...indexActions);
211
+ }
212
+ for (const action of actions) {
213
+ logger?.silly?.(`[ensure] ${dbLabel(db)}: ${action}${dryRun ? " (dryRun)" : ""}`);
214
+ }
215
+ return actions;
216
+ }
217
+ async function ensureIndex(db, tableName, index, options = {}) {
218
+ const { dryRun = false } = options;
219
+ const kind = index.unique ? "UNIQUE INDEX" : "INDEX";
220
+ const cols = index.columns.map((c) => `"${c}"`).join(", ");
221
+ const isPg = String(db?.config?.connectionString ?? "").startsWith("postgresql");
222
+ if (isPg) {
223
+ const sql2 = `CREATE ${kind} IF NOT EXISTS "${index.name}" ON "${tableName}" (${cols})`;
224
+ const { rows } = await db.raw(`SELECT 1 FROM pg_indexes WHERE indexname = ?`, [index.name]);
225
+ if (rows.length > 0) return [];
226
+ if (!dryRun) await db.raw(sql2);
227
+ return [sql2];
228
+ }
229
+ const sql = `CREATE ${kind} ${index.name} ON ${tableName} (${cols})`;
230
+ if (!dryRun) {
231
+ try {
232
+ await db.raw(sql);
233
+ } catch (error) {
234
+ if (!/already exists|duplicate/i.test(error?.message ?? "")) throw error;
235
+ return [];
236
+ }
237
+ }
238
+ return [sql];
239
+ }
240
+ async function ensureSchema(db, spec, options = {}) {
241
+ const actions = [];
242
+ for (const extension of spec.extensions ?? []) {
243
+ const { action } = await ensureExtension(db, extension, options);
244
+ if (options.dryRun) actions.push(action);
245
+ }
246
+ for (const [tableName, tableSpec] of Object.entries(spec.tables ?? {})) {
247
+ actions.push(...await ensureTable(db, tableName, tableSpec, options));
248
+ }
249
+ return { database: dbLabel(db), actions };
250
+ }
251
+
167
252
  // src/tasks/time-matcher.js
168
253
  var RANGES = ["0-59", "0-59", "0-23", "1-31", "1-12", "0-6"];
169
254
  function resolveAsterisks(field, range) {
@@ -248,29 +333,90 @@ function queueToTableNames(queueName) {
248
333
  registryTable: `${queueName}_services_registry`
249
334
  };
250
335
  }
251
- function defineTasksTable(t, db, tableNameForIndex) {
252
- t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
253
- t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
254
- t.timestamp("started_at");
255
- t.timestamp("completed_at");
256
- t.integer("priority").notNullable().defaultTo(50);
257
- t.text("schedule");
258
- t.timestamp("next_run_at").defaultTo(null);
259
- t.timestamp("past_due").defaultTo(null);
260
- t.text("name").notNullable();
261
- t.text("opid");
262
- t.jsonb("params");
263
- t.text("service_group");
264
- t.integer("instance_number");
265
- t.text("service_name");
266
- t.text("server_name");
267
- t.text("status").notNullable().defaultTo("idle");
268
- t.timestamp("status_changed_at").defaultTo(null);
269
- t.text("progress");
270
- t.boolean("success");
271
- t.jsonb("results");
272
- t.index(["service_group", "status", "priority", "created_at"], `${tableNameForIndex}_claim_idx`);
273
- t.index(["service_group", "name"], `${tableNameForIndex}_group_name_idx`);
336
+ function tasksTableSpec(tableNameForIndex) {
337
+ return {
338
+ columns: {
339
+ id: (t, db) => t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()")),
340
+ created_at: (t, db) => t.timestamp("created_at").notNullable().defaultTo(db.fn.now()),
341
+ started_at: (t) => t.timestamp("started_at"),
342
+ completed_at: (t) => t.timestamp("completed_at"),
343
+ /*
344
+ * Priority: lower number = claimed first (see claimNextRunnableTask: ORDER BY priority ASC).
345
+ * Suggested range 0–100: 0 = most urgent, 100 = least; default 50 for normal work.
346
+ */
347
+ priority: (t) => t.integer("priority").notNullable().defaultTo(50),
348
+ schedule: (t) => t.text("schedule"),
349
+ next_run_at: (t) => t.timestamp("next_run_at").defaultTo(null),
350
+ past_due: (t) => t.timestamp("past_due").defaultTo(null),
351
+ name: (t) => t.text("name").notNullable(),
352
+ opid: (t) => t.text("opid"),
353
+ params: (t) => t.jsonb("params"),
354
+ // those are target identifiers, kind of who is going to run a task.
355
+ service_group: (t) => t.text("service_group"),
356
+ // harvester, loader, photos, ...
357
+ instance_number: (t) => t.integer("instance_number"),
358
+ service_name: (t) => t.text("service_name"),
359
+ // that's a "<server_name>_<service_group>_<instance_number>"
360
+ server_name: (t) => t.text("server_name"),
361
+ // filled by runner when registering, auto.
362
+ status: (t) => t.text("status").notNullable().defaultTo("idle"),
363
+ // idle, running, completed, failed, paused
364
+ status_changed_at: (t) => t.timestamp("status_changed_at").defaultTo(null),
365
+ progress: (t) => t.text("progress"),
366
+ success: (t) => t.boolean("success"),
367
+ results: (t) => t.jsonb("results")
368
+ },
369
+ indexes: [
370
+ {
371
+ columns: ["service_group", "status", "priority", "created_at"],
372
+ name: `${tableNameForIndex}_claim_idx`
373
+ },
374
+ { columns: ["service_group", "name"], name: `${tableNameForIndex}_group_name_idx` }
375
+ ]
376
+ };
377
+ }
378
+ function registryTableSpec(registryTable) {
379
+ return {
380
+ columns: {
381
+ id: (t, db) => t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()")),
382
+ queue_name: (t) => t.text("queue_name").notNullable(),
383
+ service_group: (t) => t.text("service_group").notNullable(),
384
+ // harvester, loader, photos, ...
385
+ instance_number: (t) => t.integer("instance_number").notNullable().defaultTo(1),
386
+ service_name: (t) => t.text("service_name").notNullable(),
387
+ // that's a "<server_name>_<service_group>_<instance_number>"
388
+ server_name: (t) => t.text("server_name").notNullable(),
389
+ // filled by runner when registering, auto.
390
+ pid: (t) => t.integer("pid"),
391
+ metadata: (t) => t.json("metadata"),
392
+ created_at: (t, db) => t.timestamp("created_at").notNullable().defaultTo(db.fn.now()),
393
+ last_seen_at: (t, db) => t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now())
394
+ },
395
+ indexes: [
396
+ {
397
+ columns: ["queue_name", "service_name"],
398
+ name: `${registryTable}_queue_name_service_name_uniq`,
399
+ unique: true
400
+ },
401
+ {
402
+ columns: ["queue_name", "service_group", "last_seen_at"],
403
+ name: `${registryTable}_queue_group_seen_idx`
404
+ },
405
+ { columns: ["queue_name", "last_seen_at"], name: `${registryTable}_queue_seen_idx` }
406
+ ]
407
+ // 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.
408
+ };
409
+ }
410
+ function tasksSchemaSpec(queueName = "tasks") {
411
+ const { tasksTable, historyTable, registryTable } = queueToTableNames(queueName);
412
+ return {
413
+ extensions: ["uuid-ossp"],
414
+ tables: {
415
+ [tasksTable]: tasksTableSpec(tasksTable),
416
+ [historyTable]: tasksTableSpec(historyTable),
417
+ [registryTable]: registryTableSpec(registryTable)
418
+ }
419
+ };
274
420
  }
275
421
  function taskHistoryInsertFromQueueRow(row, overrides) {
276
422
  const { id, ...snapshot } = row;
@@ -284,60 +430,38 @@ async function ensureTaskTables(context, options = {}) {
284
430
  const queueName = options.queueName ?? "tasks";
285
431
  const recreate = options.recreate ?? false;
286
432
  const dryRun = options.dryRun ?? false;
287
- const db = getDb(context);
433
+ const databases = options.databases ?? [getDb(context)];
288
434
  const log = context.logger ?? console;
289
435
  const { tasksTable, historyTable, registryTable } = queueToTableNames(queueName);
290
- const needsTasks = recreate ? true : !await db.tableExists(tasksTable);
291
- const needsHistory = recreate ? true : !await db.tableExists(historyTable);
292
- const needsRegistry = recreate ? true : !await db.tableExists(registryTable);
293
- if (dryRun) {
294
- const plan = [];
436
+ const spec = tasksSchemaSpec(queueName);
437
+ for (const db of databases) {
438
+ const label = db?.config?.name ?? "db";
295
439
  if (recreate) {
296
- plan.push(`DROP TABLE IF EXISTS ${historyTable}, ${tasksTable}, ${registryTable}`);
440
+ if (dryRun) {
441
+ log.info?.(
442
+ `[tasks-schema] dryRun \u2014 ${label}: DROP TABLE IF EXISTS ${historyTable}, ${tasksTable}, ${registryTable}`
443
+ );
444
+ } else {
445
+ await db.schema.dropTableIfExists(historyTable);
446
+ await db.schema.dropTableIfExists(tasksTable);
447
+ await db.schema.dropTableIfExists(registryTable);
448
+ }
297
449
  }
298
- if (needsTasks) plan.push(`CREATE TABLE ${tasksTable} (tasks queue)`);
299
- if (needsHistory) plan.push(`CREATE TABLE ${historyTable} (history mirror)`);
300
- if (needsRegistry) plan.push(`CREATE TABLE ${registryTable} (services registry)`);
301
- if (plan.length === 0) {
302
- log.info?.(`[tasks-schema] dryRun \u2014 queue "${queueName}" already up to date; no DDL`);
303
- } else {
304
- log.info?.(`[tasks-schema] dryRun \u2014 would run ${plan.length} statement(s) for queue "${queueName}":`);
305
- for (const s of plan) log.info?.(` - ${s}`);
450
+ const { actions } = await ensureSchema(db, spec, { dryRun, logger: context.logger });
451
+ if (dryRun) {
452
+ if (actions.length === 0) {
453
+ log.info?.(`[tasks-schema] dryRun \u2014 ${label}: queue "${queueName}" already up to date; no DDL`);
454
+ } else {
455
+ log.info?.(
456
+ `[tasks-schema] dryRun \u2014 ${label}: would run ${actions.length} statement(s) for queue "${queueName}":`
457
+ );
458
+ for (const s of actions) log.info?.(` - ${s}`);
459
+ }
460
+ } else if (actions.length > 0) {
461
+ log.info?.(
462
+ `[tasks-schema] ${label}: applied ${actions.length} DDL statement(s) for queue "${queueName}"`
463
+ );
306
464
  }
307
- return;
308
- }
309
- if (recreate) {
310
- await db.schema.dropTableIfExists(historyTable);
311
- await db.schema.dropTableIfExists(tasksTable);
312
- await db.schema.dropTableIfExists(registryTable);
313
- }
314
- if (needsTasks) {
315
- await db.raw(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
316
- await db.schema.createTable(tasksTable, (t) => {
317
- defineTasksTable(t, db, tasksTable);
318
- });
319
- }
320
- if (needsHistory) {
321
- await db.schema.createTable(historyTable, (t) => {
322
- defineTasksTable(t, db, historyTable);
323
- });
324
- }
325
- if (needsRegistry) {
326
- await db.schema.createTable(registryTable, (t) => {
327
- t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
328
- t.text("queue_name").notNullable();
329
- t.text("service_group").notNullable();
330
- t.integer("instance_number").notNullable().defaultTo(1);
331
- t.text("service_name").notNullable();
332
- t.text("server_name").notNullable();
333
- t.integer("pid");
334
- t.json("metadata");
335
- t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
336
- t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now());
337
- t.unique(["queue_name", "service_name"], `${registryTable}_queue_name_service_name_uniq`);
338
- t.index(["queue_name", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
339
- t.index(["queue_name", "last_seen_at"], `${registryTable}_queue_seen_idx`);
340
- });
341
465
  }
342
466
  }
343
467
  async function enqueueTask(context, options) {