@happyvertical/smrt-cli 0.40.70 → 0.41.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/AGENTS.md CHANGED
@@ -17,6 +17,8 @@ smrt db:migrate-uuid # Convert schema-declared UUID text columns after d
17
17
  smrt db:diff # Show schema differences without generating migration files
18
18
  smrt db:rollback # Roll back migrations by executing their recorded DOWN
19
19
  smrt db:rollback --mark-only # Record-only flip; schema deliberately untouched
20
+ smrt db:prune # Prune framework system tables to their retention windows
21
+ smrt db:prune --dry-run # Same predicates, counted rather than deleted
20
22
  smrt docs:agents # Generate .agents/smrt-framework.md
21
23
  smrt docs:claude # Deprecated alias writing .claude/smrt-framework.md
22
24
  smrt dev:knowledge-* # Deterministic agent knowledge index/check/diff
@@ -139,6 +141,26 @@ the exact statement `db:migrate` records for `diff.added_tables`.
139
141
  Reverting a non-`create_table` change is a forward operation: update the
140
142
  `@smrt` object definitions and run `db:migrate` again.
141
143
 
144
+ ## `db:prune` is the retention cron entry point (#2375)
145
+
146
+ Runs `runRetentionSweep()` from `@happyvertical/smrt-core` over every
147
+ framework-owned system table plus every task other installed packages
148
+ registered (`_smrt_jobs`/`_smrt_job_events` from `smrt-jobs`, expired
149
+ sessions/magic-link tokens/CLI-auth requests from `smrt-users`).
150
+
151
+ - Defaults are the framework's documented retention windows; `retention` in
152
+ `smrt.config` overrides them persistently, and `--changes-days`,
153
+ `--usage-days`, `--dispatch-days` override them for one run.
154
+ - `--skip` takes **task** names, not table names — the same names the report
155
+ and `--json` print, so a package-contributed task is skipped the same way a
156
+ built-in one is.
157
+ - `--dry-run` counts with the identical predicates instead of deleting; the
158
+ report says `would prune`.
159
+ - The exit code is non-zero when **any** task failed, so a partial sweep never
160
+ looks clean to cron. Individual task failures never abort the others.
161
+ - Deployments running a jobs `TaskRunner` already get the same sweep every six
162
+ hours; this command is for those that do not, and for one-off operator runs.
163
+
142
164
  ## Architecture
143
165
 
144
166
  - **Lazy command loading**: commands loaded on-demand via dynamic import (~100ms overhead on first use)
@@ -5,7 +5,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFile
5
5
  import * as path from "node:path";
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
- import { ObjectRegistry, SchemaComparer, checkLiveSchemaParity, createQualifiedName, generateDDLForEngine, getClassName, isQualifiedName, migratePostgresSystemTimestamps, parseQualifiedName, planPostgresSystemTimestampMigrations } from "@happyvertical/smrt-core";
8
+ import { ObjectRegistry, SchemaComparer, checkLiveSchemaParity, createQualifiedName, ensureDeferredSystemTableCompatibility, generateDDLForEngine, getClassName, isQualifiedName, migratePostgresSystemTimestamps, parseQualifiedName, planPostgresSystemTimestampMigrations } from "@happyvertical/smrt-core";
9
9
  import { loadExternalManifestSync } from "@happyvertical/smrt-core/manifest";
10
10
  import { access, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
11
11
  import { createLogger } from "@happyvertical/logger";
@@ -1400,6 +1400,191 @@ function formatDateTime(date) {
1400
1400
  return date.toISOString().replace("T", " ").substring(0, 19);
1401
1401
  }
1402
1402
  //#endregion
1403
+ //#region src/commands/db-prune.ts
1404
+ /**
1405
+ * Packages that contribute retention tasks by registering them on import.
1406
+ *
1407
+ * `db:prune` runs in the CLI's own process, so a task only reaches the sweep
1408
+ * if this process actually loaded the package that registers it. These are
1409
+ * imported optionally — a project that does not depend on jobs or users simply
1410
+ * has no jobs or users tasks, which is the correct outcome, not an error.
1411
+ */
1412
+ var RETENTION_TASK_PACKAGES = ["@happyvertical/smrt-jobs", "@happyvertical/smrt-users"];
1413
+ /**
1414
+ * Load every installed package that contributes retention tasks.
1415
+ *
1416
+ * A rejected import is always treated as "not installed" — this function
1417
+ * cannot reliably tell a genuine module-resolution miss apart from a package
1418
+ * that resolved but threw during its own top-level evaluation (error shapes
1419
+ * differ across bundlers and runtimes, and `importPackage` is caller-supplied
1420
+ * for exactly that flexibility). It still logs the message on `stderr`
1421
+ * rather than swallowing it outright, so an operator can tell "this project
1422
+ * doesn't depend on jobs/users" apart from "smrt-jobs is installed but broke
1423
+ * on import" without the sweep itself needing to fail over an optional
1424
+ * dependency.
1425
+ *
1426
+ * @returns The specifiers that loaded, in declaration order.
1427
+ */
1428
+ async function loadRetentionTaskPackages(importPackage) {
1429
+ const loaded = [];
1430
+ for (const specifier of RETENTION_TASK_PACKAGES) try {
1431
+ await importPackage(specifier);
1432
+ loaded.push(specifier);
1433
+ } catch (error) {
1434
+ const message = error instanceof Error ? error.message : String(error);
1435
+ console.warn(`⚠️ Could not load ${specifier} (${message}). Treating its retention tasks as not contributed — if the package is installed, this may be a real import failure rather than a missing dependency.`);
1436
+ }
1437
+ return loaded;
1438
+ }
1439
+ /**
1440
+ * Merge command-line overrides onto the configured retention policy.
1441
+ *
1442
+ * `--skip` names tasks, not tables, so it covers both the built-in tables and
1443
+ * anything a package registered — the same names `db:prune --json` reports.
1444
+ */
1445
+ function buildPrunePolicy(configured, options) {
1446
+ const policy = { ...configured ?? {} };
1447
+ policy.dryRun = options["dry-run"] || (configured?.dryRun ?? false);
1448
+ if (options["changes-days"] !== void 0) policy.changes = {
1449
+ ...policy.changes === false ? {} : policy.changes ?? {},
1450
+ maxAgeDays: options["changes-days"]
1451
+ };
1452
+ if (options["usage-days"] !== void 0) policy.aiUsage = {
1453
+ ...policy.aiUsage === false ? {} : policy.aiUsage ?? {},
1454
+ maxAgeDays: options["usage-days"]
1455
+ };
1456
+ if (options["dispatch-days"] !== void 0) policy.dispatch = {
1457
+ ...policy.dispatch === false ? {} : policy.dispatch ?? {},
1458
+ completedOlderThanDays: options["dispatch-days"]
1459
+ };
1460
+ const skipped = (options.skip ?? "").split(",").map((name) => name.trim()).filter((name) => name.length > 0);
1461
+ for (const name of skipped) switch (name) {
1462
+ case "changes":
1463
+ policy.changes = false;
1464
+ break;
1465
+ case "ai-usage":
1466
+ policy.aiUsage = false;
1467
+ break;
1468
+ case "contexts":
1469
+ policy.contexts = false;
1470
+ break;
1471
+ case "dispatch":
1472
+ policy.dispatch = false;
1473
+ break;
1474
+ default: policy.tasks = {
1475
+ ...policy.tasks ?? {},
1476
+ [name]: false
1477
+ };
1478
+ }
1479
+ return policy;
1480
+ }
1481
+ /** Render a completed sweep as an operator-readable table. */
1482
+ function formatSweepResult(result) {
1483
+ const lines = [];
1484
+ const verb = result.dryRun ? "would prune" : "pruned";
1485
+ for (const task of result.tasks) {
1486
+ const status = task.error ? `error: ${task.error}` : task.skipped ? task.skipped : `${verb} ${task.pruned}`;
1487
+ const detail = task.details ? ` (${Object.entries(task.details).map(([key, value]) => `${key}=${value}`).join(", ")})` : "";
1488
+ lines.push(` ${task.task.padEnd(24)}${status}${detail}`);
1489
+ }
1490
+ lines.push("");
1491
+ lines.push(` ${"total".padEnd(24)}${verb} ${result.pruned} row(s) in ${result.durationMs}ms`);
1492
+ return lines.join("\n");
1493
+ }
1494
+ /**
1495
+ * Names passed to `--skip` that no task in the completed sweep answered to.
1496
+ *
1497
+ * A typo and a package that was never installed look identical on the command
1498
+ * line, so the command says which names it did not recognize rather than
1499
+ * silently doing less than the operator asked for.
1500
+ */
1501
+ function unmatchedSkipNames(skip, result) {
1502
+ const known = new Set(result.tasks.map((task) => task.task));
1503
+ return (skip ?? "").split(",").map((name) => name.trim()).filter((name) => name.length > 0 && !known.has(name));
1504
+ }
1505
+ var dbPruneCommand = {
1506
+ name: "db:prune",
1507
+ description: "Prune framework-owned system tables to their retention windows",
1508
+ args: [],
1509
+ options: {
1510
+ "dry-run": {
1511
+ type: "boolean",
1512
+ description: "Report what would be deleted without deleting it",
1513
+ default: false
1514
+ },
1515
+ json: {
1516
+ type: "boolean",
1517
+ description: "Output as JSON (for CI/cron integration)",
1518
+ default: false,
1519
+ short: "j"
1520
+ },
1521
+ "changes-days": {
1522
+ type: "number",
1523
+ description: "Retention window for _smrt_changes, in days"
1524
+ },
1525
+ "usage-days": {
1526
+ type: "number",
1527
+ description: "Retention window for _smrt_ai_usage, in days"
1528
+ },
1529
+ "dispatch-days": {
1530
+ type: "number",
1531
+ description: "Retention window for completed _smrt_dispatch rows, in days"
1532
+ },
1533
+ skip: {
1534
+ type: "string",
1535
+ description: "Comma-separated task names to skip (changes, ai-usage, contexts, dispatch, …)"
1536
+ }
1537
+ },
1538
+ handler: async (_args, options) => {
1539
+ let db;
1540
+ try {
1541
+ const { getPackageConfig } = await import("@happyvertical/smrt-config");
1542
+ const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
1543
+ const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
1544
+ if (!config.database?.url) {
1545
+ const message = "Database not configured. Set database.url in smrt.config.ts.";
1546
+ if (options.json) console.log(JSON.stringify({ error: message }));
1547
+ else console.error(`\n❌ ${message}\n`);
1548
+ process.exitCode = 1;
1549
+ return;
1550
+ }
1551
+ const dbUrl = config.database.url;
1552
+ const dbType = config.database.type || "sqlite";
1553
+ const { getDatabase } = await import("@happyvertical/sql");
1554
+ db = await getDatabase({
1555
+ type: dbType,
1556
+ url: dbUrl
1557
+ });
1558
+ const { config: smrtConfig, importOptionalDependency, runRetentionSweep } = await import("@happyvertical/smrt-core");
1559
+ const loaded = await loadRetentionTaskPackages((specifier) => importOptionalDependency(specifier, "Install it in the project to include its retention tasks."));
1560
+ const policy = buildPrunePolicy(smrtConfig.toJSON().retention, options);
1561
+ const result = await runRetentionSweep(db, policy);
1562
+ const unmatched = unmatchedSkipNames(options.skip, result);
1563
+ if (options.json) console.log(JSON.stringify({
1564
+ ...result,
1565
+ contributors: loaded,
1566
+ unmatched
1567
+ }, null, 2));
1568
+ else {
1569
+ console.log(`\n🧹 Retention sweep${result.dryRun ? " (dry run)" : ""}\n`);
1570
+ console.log(`Database: ${formatDatabaseDisplayUrl(dbType, dbUrl)}\n`);
1571
+ console.log(formatSweepResult(result));
1572
+ console.log();
1573
+ if (unmatched.length > 0) console.warn(`⚠️ --skip named no known task: ${unmatched.join(", ")}. Check the spelling, or the package that registers it may not be installed.
1574
+ `);
1575
+ }
1576
+ if (result.failed) process.exitCode = 1;
1577
+ } catch (error) {
1578
+ const message = error instanceof Error ? error.message : String(error);
1579
+ if (options.json) console.log(JSON.stringify({ error: message }));
1580
+ else console.error(`\n❌ Retention sweep failed: ${message}\n`);
1581
+ process.exitCode = 1;
1582
+ } finally {
1583
+ await closeDatabaseConnection(db);
1584
+ }
1585
+ }
1586
+ };
1587
+ //#endregion
1403
1588
  //#region src/commands/db-rollback.ts
1404
1589
  /**
1405
1590
  * Name prefix of the only migration class `db:migrate` records a DOWN script
@@ -7084,6 +7269,28 @@ function resolveDDLPreviewEngine(dbType) {
7084
7269
  default: return "sqlite";
7085
7270
  }
7086
7271
  }
7272
+ /**
7273
+ * Run the framework's deferred system-table compatibility pass after
7274
+ * `db:migrate` has created the tables it reshapes (issue #2376).
7275
+ *
7276
+ * `_smrt_jobs` and `_smrt_job_events` are dual-owned — created here from the
7277
+ * jobs manifest, then given their compatibility columns and indexes by
7278
+ * `@happyvertical/smrt-core`. On a fresh install the framework bootstrap runs
7279
+ * before those tables exist, so without this call the pass would first become
7280
+ * reachable at the next process start.
7281
+ *
7282
+ * Best-effort: the pass is idempotent and re-runs on the next boot, so a
7283
+ * failure here must not fail an otherwise-successful migration.
7284
+ */
7285
+ async function settleDeferredCompatibilityAfterMigrate(db, dbType, { verbose }) {
7286
+ try {
7287
+ const { settled } = await ensureDeferredSystemTableCompatibility(db, dbType);
7288
+ if (verbose) console.log(settled ? "Deferred system-table compatibility settled (_smrt_jobs, _smrt_job_events)\n" : "Deferred system-table compatibility pending (jobs tables not present)\n");
7289
+ } catch (error) {
7290
+ console.warn(`⚠️ Deferred system-table compatibility did not complete: ${error instanceof Error ? error.message : String(error)}`);
7291
+ console.warn(" It will be retried the next time the framework starts.\n");
7292
+ }
7293
+ }
7087
7294
  function formatStiConflictIdentity(conflict) {
7088
7295
  const entries = Object.entries(conflict.conflictIdentity);
7089
7296
  return `${entries.length > 0 ? entries.map(([column, value]) => `${column}=${JSON.stringify(value)}`).join(", ") : "no non-_meta_type conflict columns"}${conflict.legacyId || conflict.qualifiedId ? ` (legacy id: ${conflict.legacyId ?? "unknown"}, qualified id: ${conflict.qualifiedId ?? "unknown"})` : ""}`;
@@ -7175,7 +7382,7 @@ function resolveDeclaredViteMajor(packageJson) {
7175
7382
  function assessDecoratorSupport(input) {
7176
7383
  const { viteMajor, viteConfigContent, tsconfigContent } = input;
7177
7384
  const hasOxcDecorator = viteConfigContent !== null && OXC_DECORATOR_BLOCK_RE.test(viteConfigContent);
7178
- const hasTsconfigDecorators = tsconfigContent !== null && tsconfigContent.includes("experimentalDecorators");
7385
+ const hasTsconfigDecorators = tsconfigContent?.includes("experimentalDecorators");
7179
7386
  const isVite8Plus = viteMajor !== null && viteMajor >= 8;
7180
7387
  if (hasOxcDecorator) return {
7181
7388
  status: "ok",
@@ -8229,11 +8436,14 @@ export default testManifest;
8229
8436
  stiErrorCount,
8230
8437
  dryRun: isDryRun
8231
8438
  })) process.exitCode = 1;
8232
- if (!isDryRun) assertSchemaContract(await evaluateSchemaContract({
8233
- discovered,
8234
- schemaContract: config.schemaContract,
8235
- db
8236
- }));
8439
+ if (!isDryRun) {
8440
+ await settleDeferredCompatibilityAfterMigrate(db, dbType, { verbose: Boolean(options.verbose) });
8441
+ assertSchemaContract(await evaluateSchemaContract({
8442
+ discovered,
8443
+ schemaContract: config.schemaContract,
8444
+ db
8445
+ }));
8446
+ }
8237
8447
  console.log("💡 Next steps:");
8238
8448
  console.log(" - Run: smrt db:status (view migration status)");
8239
8449
  console.log(" - Run: smrt db:history (view migration history)");
@@ -8504,6 +8714,7 @@ export default testManifest;
8504
8714
  "db:rollback": dbRollbackCommand,
8505
8715
  "db:generate": dbGenerateCommand,
8506
8716
  "db:migrate-uuid": dbMigrateUuidCommand,
8717
+ "db:prune": dbPruneCommand,
8507
8718
  "config:export": configExportCommand,
8508
8719
  export: exportCommand
8509
8720
  };
package/dist/index.js CHANGED
@@ -49,63 +49,63 @@ var _playgroundCommands = null;
49
49
  var _workbenchCommands = null;
50
50
  async function getGnodeCommands() {
51
51
  if (!_gnodeCommands) {
52
- const { gnodeCommands } = await import("./commands-Df9OZ6tG.js");
52
+ const { gnodeCommands } = await import("./commands-DjgCD-0o.js");
53
53
  _gnodeCommands = gnodeCommands;
54
54
  }
55
55
  return _gnodeCommands;
56
56
  }
57
57
  async function getGitCommands() {
58
58
  if (!_gitCommands) {
59
- const { gitCommands } = await import("./commands-Df9OZ6tG.js");
59
+ const { gitCommands } = await import("./commands-DjgCD-0o.js");
60
60
  _gitCommands = gitCommands;
61
61
  }
62
62
  return _gitCommands;
63
63
  }
64
64
  async function getGenerateCommands() {
65
65
  if (!_generateCommands) {
66
- const { generateCommands } = await import("./commands-Df9OZ6tG.js");
66
+ const { generateCommands } = await import("./commands-DjgCD-0o.js");
67
67
  _generateCommands = generateCommands;
68
68
  }
69
69
  return _generateCommands;
70
70
  }
71
71
  async function getInitCommands() {
72
72
  if (!_initCommands) {
73
- const { initCommands } = await import("./commands-Df9OZ6tG.js");
73
+ const { initCommands } = await import("./commands-DjgCD-0o.js");
74
74
  _initCommands = initCommands;
75
75
  }
76
76
  return _initCommands;
77
77
  }
78
78
  async function getUtilityCommands() {
79
79
  if (!_utilityCommands) {
80
- const { utilityCommands } = await import("./commands-Df9OZ6tG.js");
80
+ const { utilityCommands } = await import("./commands-DjgCD-0o.js");
81
81
  _utilityCommands = utilityCommands;
82
82
  }
83
83
  return _utilityCommands;
84
84
  }
85
85
  async function getDispatchCommands() {
86
86
  if (!_dispatchCommands) {
87
- const { dispatchCommands } = await import("./commands-Df9OZ6tG.js");
87
+ const { dispatchCommands } = await import("./commands-DjgCD-0o.js");
88
88
  _dispatchCommands = dispatchCommands;
89
89
  }
90
90
  return _dispatchCommands;
91
91
  }
92
92
  async function getDocsCommands() {
93
93
  if (!_docsCommands) {
94
- const { docsCommands } = await import("./commands-Df9OZ6tG.js");
94
+ const { docsCommands } = await import("./commands-DjgCD-0o.js");
95
95
  _docsCommands = docsCommands;
96
96
  }
97
97
  return _docsCommands;
98
98
  }
99
99
  async function getPlaygroundCommands() {
100
100
  if (!_playgroundCommands) {
101
- const { playgroundCommands } = await import("./commands-Df9OZ6tG.js");
101
+ const { playgroundCommands } = await import("./commands-DjgCD-0o.js");
102
102
  _playgroundCommands = playgroundCommands;
103
103
  }
104
104
  return _playgroundCommands;
105
105
  }
106
106
  async function getWorkbenchCommands() {
107
107
  if (!_workbenchCommands) {
108
- const { workbenchCommands } = await import("./commands-Df9OZ6tG.js");
108
+ const { workbenchCommands } = await import("./commands-DjgCD-0o.js");
109
109
  _workbenchCommands = workbenchCommands;
110
110
  }
111
111
  return _workbenchCommands;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-cli",
3
- "version": "0.40.70",
3
+ "version": "0.41.0",
4
4
  "description": "Developer CLI for SMRT framework - introspection, testing, and project management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -32,12 +32,12 @@
32
32
  "acorn": "^8.17.0",
33
33
  "fast-glob": "3.3.3",
34
34
  "tar": "^7.5.19",
35
- "@happyvertical/smrt-config": "0.40.70",
36
- "@happyvertical/smrt-core": "0.40.70",
37
- "@happyvertical/smrt-dev-mcp": "0.40.70",
38
- "@happyvertical/smrt-playground": "0.40.70",
39
- "@happyvertical/smrt-types": "0.40.70",
40
- "@happyvertical/smrt-agents": "0.40.70"
35
+ "@happyvertical/smrt-agents": "0.41.0",
36
+ "@happyvertical/smrt-config": "0.41.0",
37
+ "@happyvertical/smrt-dev-mcp": "0.41.0",
38
+ "@happyvertical/smrt-core": "0.41.0",
39
+ "@happyvertical/smrt-types": "0.41.0",
40
+ "@happyvertical/smrt-playground": "0.41.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "24.13.2",