@nmakarov/cli-toolkit 0.33.0 → 0.37.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.js CHANGED
@@ -88,6 +88,91 @@ import os from "os";
88
88
  // src/tasks/taskUtils.js
89
89
  import { randomUUID } from "crypto";
90
90
 
91
+ // src/db/ensure.js
92
+ var dbLabel = (db) => db?.config?.name ?? "db";
93
+ async function ensureExtension(db, name, options = {}) {
94
+ const action = `CREATE EXTENSION IF NOT EXISTS "${name}"`;
95
+ if (!options.dryRun) {
96
+ await db.raw(action);
97
+ }
98
+ options.logger?.silly?.(`[ensure] ${dbLabel(db)}: ${action}${options.dryRun ? " (dryRun)" : ""}`);
99
+ return { action };
100
+ }
101
+ async function ensureTable(db, tableName, spec, options = {}) {
102
+ const { dryRun = false, logger } = options;
103
+ const actions = [];
104
+ const exists = await db.tableExists(tableName);
105
+ if (!exists) {
106
+ actions.push(`CREATE TABLE ${tableName} (${Object.keys(spec.columns).length} columns)`);
107
+ if (!dryRun) {
108
+ await db.schema.createTable(tableName, (t) => {
109
+ for (const define of Object.values(spec.columns)) {
110
+ define(t, db);
111
+ }
112
+ });
113
+ }
114
+ } else {
115
+ const missing = [];
116
+ for (const column of Object.keys(spec.columns)) {
117
+ if (!await db.schema.hasColumn(tableName, column)) {
118
+ missing.push(column);
119
+ }
120
+ }
121
+ if (missing.length > 0) {
122
+ actions.push(`ALTER TABLE ${tableName} ADD COLUMN ${missing.join(", ")}`);
123
+ if (!dryRun) {
124
+ await db.schema.alterTable(tableName, (t) => {
125
+ for (const column of missing) {
126
+ spec.columns[column](t, db);
127
+ }
128
+ });
129
+ }
130
+ }
131
+ }
132
+ for (const index of spec.indexes ?? []) {
133
+ const indexActions = await ensureIndex(db, tableName, index, options);
134
+ actions.push(...indexActions);
135
+ }
136
+ for (const action of actions) {
137
+ logger?.silly?.(`[ensure] ${dbLabel(db)}: ${action}${dryRun ? " (dryRun)" : ""}`);
138
+ }
139
+ return actions;
140
+ }
141
+ async function ensureIndex(db, tableName, index, options = {}) {
142
+ const { dryRun = false } = options;
143
+ const kind = index.unique ? "UNIQUE INDEX" : "INDEX";
144
+ const cols = index.columns.map((c) => `"${c}"`).join(", ");
145
+ const isPg = String(db?.config?.connectionString ?? "").startsWith("postgresql");
146
+ if (isPg) {
147
+ const sql2 = `CREATE ${kind} IF NOT EXISTS "${index.name}" ON "${tableName}" (${cols})`;
148
+ const { rows } = await db.raw(`SELECT 1 FROM pg_indexes WHERE indexname = ?`, [index.name]);
149
+ if (rows.length > 0) return [];
150
+ if (!dryRun) await db.raw(sql2);
151
+ return [sql2];
152
+ }
153
+ const sql = `CREATE ${kind} ${index.name} ON ${tableName} (${cols})`;
154
+ if (!dryRun) {
155
+ try {
156
+ await db.raw(sql);
157
+ } catch (error) {
158
+ if (!/already exists|duplicate/i.test(error?.message ?? "")) throw error;
159
+ return [];
160
+ }
161
+ }
162
+ return [sql];
163
+ }
164
+ async function ensureSchema(db, spec, options = {}) {
165
+ const actions = [];
166
+ for (const extension of spec.extensions ?? []) {
167
+ const { action } = await ensureExtension(db, extension, options);
168
+ if (options.dryRun) actions.push(action);
169
+ }
170
+ for (const [tableName, tableSpec] of Object.entries(spec.tables ?? {})) {
171
+ actions.push(...await ensureTable(db, tableName, tableSpec, options));
172
+ }
173
+ return { database: dbLabel(db), actions };
174
+ }
175
+
91
176
  // src/tasks/time-matcher.js
92
177
  var RANGES = ["0-59", "0-59", "0-23", "1-31", "1-12", "0-6"];
93
178
  function resolveAsterisks(field, range) {
@@ -172,29 +257,90 @@ function queueToTableNames(queueName) {
172
257
  registryTable: `${queueName}_services_registry`
173
258
  };
174
259
  }
175
- function defineTasksTable(t, db, tableNameForIndex) {
176
- t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
177
- t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
178
- t.timestamp("started_at");
179
- t.timestamp("completed_at");
180
- t.integer("priority").notNullable().defaultTo(50);
181
- t.text("schedule");
182
- t.timestamp("next_run_at").defaultTo(null);
183
- t.timestamp("past_due").defaultTo(null);
184
- t.text("name").notNullable();
185
- t.text("opid");
186
- t.jsonb("params");
187
- t.text("service_group");
188
- t.integer("instance_number");
189
- t.text("service_name");
190
- t.text("server_name");
191
- t.text("status").notNullable().defaultTo("idle");
192
- t.timestamp("status_changed_at").defaultTo(null);
193
- t.text("progress");
194
- t.boolean("success");
195
- t.jsonb("results");
196
- t.index(["service_group", "status", "priority", "created_at"], `${tableNameForIndex}_claim_idx`);
197
- t.index(["service_group", "name"], `${tableNameForIndex}_group_name_idx`);
260
+ function tasksTableSpec(tableNameForIndex) {
261
+ return {
262
+ columns: {
263
+ id: (t, db) => t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()")),
264
+ created_at: (t, db) => t.timestamp("created_at").notNullable().defaultTo(db.fn.now()),
265
+ started_at: (t) => t.timestamp("started_at"),
266
+ completed_at: (t) => t.timestamp("completed_at"),
267
+ /*
268
+ * Priority: lower number = claimed first (see claimNextRunnableTask: ORDER BY priority ASC).
269
+ * Suggested range 0–100: 0 = most urgent, 100 = least; default 50 for normal work.
270
+ */
271
+ priority: (t) => t.integer("priority").notNullable().defaultTo(50),
272
+ schedule: (t) => t.text("schedule"),
273
+ next_run_at: (t) => t.timestamp("next_run_at").defaultTo(null),
274
+ past_due: (t) => t.timestamp("past_due").defaultTo(null),
275
+ name: (t) => t.text("name").notNullable(),
276
+ opid: (t) => t.text("opid"),
277
+ params: (t) => t.jsonb("params"),
278
+ // those are target identifiers, kind of who is going to run a task.
279
+ service_group: (t) => t.text("service_group"),
280
+ // harvester, loader, photos, ...
281
+ instance_number: (t) => t.integer("instance_number"),
282
+ service_name: (t) => t.text("service_name"),
283
+ // that's a "<server_name>_<service_group>_<instance_number>"
284
+ server_name: (t) => t.text("server_name"),
285
+ // filled by runner when registering, auto.
286
+ status: (t) => t.text("status").notNullable().defaultTo("idle"),
287
+ // idle, running, completed, failed, paused
288
+ status_changed_at: (t) => t.timestamp("status_changed_at").defaultTo(null),
289
+ progress: (t) => t.text("progress"),
290
+ success: (t) => t.boolean("success"),
291
+ results: (t) => t.jsonb("results")
292
+ },
293
+ indexes: [
294
+ {
295
+ columns: ["service_group", "status", "priority", "created_at"],
296
+ name: `${tableNameForIndex}_claim_idx`
297
+ },
298
+ { columns: ["service_group", "name"], name: `${tableNameForIndex}_group_name_idx` }
299
+ ]
300
+ };
301
+ }
302
+ function registryTableSpec(registryTable) {
303
+ return {
304
+ columns: {
305
+ id: (t, db) => t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()")),
306
+ queue_name: (t) => t.text("queue_name").notNullable(),
307
+ service_group: (t) => t.text("service_group").notNullable(),
308
+ // harvester, loader, photos, ...
309
+ instance_number: (t) => t.integer("instance_number").notNullable().defaultTo(1),
310
+ service_name: (t) => t.text("service_name").notNullable(),
311
+ // that's a "<server_name>_<service_group>_<instance_number>"
312
+ server_name: (t) => t.text("server_name").notNullable(),
313
+ // filled by runner when registering, auto.
314
+ pid: (t) => t.integer("pid"),
315
+ metadata: (t) => t.json("metadata"),
316
+ created_at: (t, db) => t.timestamp("created_at").notNullable().defaultTo(db.fn.now()),
317
+ last_seen_at: (t, db) => t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now())
318
+ },
319
+ indexes: [
320
+ {
321
+ columns: ["queue_name", "service_name"],
322
+ name: `${registryTable}_queue_name_service_name_uniq`,
323
+ unique: true
324
+ },
325
+ {
326
+ columns: ["queue_name", "service_group", "last_seen_at"],
327
+ name: `${registryTable}_queue_group_seen_idx`
328
+ },
329
+ { columns: ["queue_name", "last_seen_at"], name: `${registryTable}_queue_seen_idx` }
330
+ ]
331
+ // 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.
332
+ };
333
+ }
334
+ function tasksSchemaSpec(queueName = "tasks") {
335
+ const { tasksTable, historyTable, registryTable } = queueToTableNames(queueName);
336
+ return {
337
+ extensions: ["uuid-ossp"],
338
+ tables: {
339
+ [tasksTable]: tasksTableSpec(tasksTable),
340
+ [historyTable]: tasksTableSpec(historyTable),
341
+ [registryTable]: registryTableSpec(registryTable)
342
+ }
343
+ };
198
344
  }
199
345
  function taskHistoryInsertFromQueueRow(row, overrides) {
200
346
  const { id, ...snapshot } = row;
@@ -208,60 +354,38 @@ async function ensureTaskTables(context, options = {}) {
208
354
  const queueName = options.queueName ?? "tasks";
209
355
  const recreate = options.recreate ?? false;
210
356
  const dryRun = options.dryRun ?? false;
211
- const db = getDb(context);
357
+ const databases = options.databases ?? [getDb(context)];
212
358
  const log = context.logger ?? console;
213
359
  const { tasksTable, historyTable, registryTable } = queueToTableNames(queueName);
214
- const needsTasks = recreate ? true : !await db.tableExists(tasksTable);
215
- const needsHistory = recreate ? true : !await db.tableExists(historyTable);
216
- const needsRegistry = recreate ? true : !await db.tableExists(registryTable);
217
- if (dryRun) {
218
- const plan = [];
360
+ const spec = tasksSchemaSpec(queueName);
361
+ for (const db of databases) {
362
+ const label = db?.config?.name ?? "db";
219
363
  if (recreate) {
220
- plan.push(`DROP TABLE IF EXISTS ${historyTable}, ${tasksTable}, ${registryTable}`);
364
+ if (dryRun) {
365
+ log.info?.(
366
+ `[tasks-schema] dryRun \u2014 ${label}: DROP TABLE IF EXISTS ${historyTable}, ${tasksTable}, ${registryTable}`
367
+ );
368
+ } else {
369
+ await db.schema.dropTableIfExists(historyTable);
370
+ await db.schema.dropTableIfExists(tasksTable);
371
+ await db.schema.dropTableIfExists(registryTable);
372
+ }
221
373
  }
222
- if (needsTasks) plan.push(`CREATE TABLE ${tasksTable} (tasks queue)`);
223
- if (needsHistory) plan.push(`CREATE TABLE ${historyTable} (history mirror)`);
224
- if (needsRegistry) plan.push(`CREATE TABLE ${registryTable} (services registry)`);
225
- if (plan.length === 0) {
226
- log.info?.(`[tasks-schema] dryRun \u2014 queue "${queueName}" already up to date; no DDL`);
227
- } else {
228
- log.info?.(`[tasks-schema] dryRun \u2014 would run ${plan.length} statement(s) for queue "${queueName}":`);
229
- for (const s of plan) log.info?.(` - ${s}`);
374
+ const { actions } = await ensureSchema(db, spec, { dryRun, logger: context.logger });
375
+ if (dryRun) {
376
+ if (actions.length === 0) {
377
+ log.info?.(`[tasks-schema] dryRun \u2014 ${label}: queue "${queueName}" already up to date; no DDL`);
378
+ } else {
379
+ log.info?.(
380
+ `[tasks-schema] dryRun \u2014 ${label}: would run ${actions.length} statement(s) for queue "${queueName}":`
381
+ );
382
+ for (const s of actions) log.info?.(` - ${s}`);
383
+ }
384
+ } else if (actions.length > 0) {
385
+ log.info?.(
386
+ `[tasks-schema] ${label}: applied ${actions.length} DDL statement(s) for queue "${queueName}"`
387
+ );
230
388
  }
231
- return;
232
- }
233
- if (recreate) {
234
- await db.schema.dropTableIfExists(historyTable);
235
- await db.schema.dropTableIfExists(tasksTable);
236
- await db.schema.dropTableIfExists(registryTable);
237
- }
238
- if (needsTasks) {
239
- await db.raw(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
240
- await db.schema.createTable(tasksTable, (t) => {
241
- defineTasksTable(t, db, tasksTable);
242
- });
243
- }
244
- if (needsHistory) {
245
- await db.schema.createTable(historyTable, (t) => {
246
- defineTasksTable(t, db, historyTable);
247
- });
248
- }
249
- if (needsRegistry) {
250
- await db.schema.createTable(registryTable, (t) => {
251
- t.uuid("id").primary().defaultTo(db.raw("uuid_generate_v4()"));
252
- t.text("queue_name").notNullable();
253
- t.text("service_group").notNullable();
254
- t.integer("instance_number").notNullable().defaultTo(1);
255
- t.text("service_name").notNullable();
256
- t.text("server_name").notNullable();
257
- t.integer("pid");
258
- t.json("metadata");
259
- t.timestamp("created_at").notNullable().defaultTo(db.fn.now());
260
- t.timestamp("last_seen_at").notNullable().defaultTo(db.fn.now());
261
- t.unique(["queue_name", "service_name"], `${registryTable}_queue_name_service_name_uniq`);
262
- t.index(["queue_name", "service_group", "last_seen_at"], `${registryTable}_queue_group_seen_idx`);
263
- t.index(["queue_name", "last_seen_at"], `${registryTable}_queue_seen_idx`);
264
- });
265
389
  }
266
390
  }
267
391
  async function enqueueTask(context, options) {