@hyperfixation/cli 0.1.5 → 0.1.7

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.
@@ -10,8 +10,40 @@ import { openAppState } from "./state.js";
10
10
  export const SCRATCH_SUFFIX = "_restore_check";
11
11
  /** Postgres truncates an identifier past this, which would collide with the live database. */
12
12
  const MAX_IDENTIFIER_BYTES = 63;
13
- /** Older than this and the dump gets a warning line; it never changes the exit code. */
14
- export const STALE_DUMP_HOURS = 36;
13
+ /** Older than this and the dump gets a WARN line, which as in `hf doctor` — exits 1. */
14
+ export const STALE_DUMP_HOURS = 24;
15
+ /**
16
+ * Tables a live row is only ever added to, so a live count above the dump's is the app working,
17
+ * not lost data.
18
+ *
19
+ * X1 found this the hard way: against a 1.3-hour-old dump of a running app, 9 of 24 tables
20
+ * "mismatched" purely from churn since the dump, and the same check against a fresh dump matched
21
+ * all 24. A table earns a place here only when no code path deletes from it and none updates it
22
+ * in a way that lowers its count — checked against `packages/db/src/schema` and every statement
23
+ * in `core`, `workflows`, `auth` and `admin`. Anything else, including a table whose rows merely
24
+ * look permanent, stays exact: a false `ok` here hides exactly the data loss this command exists
25
+ * to catch.
26
+ */
27
+ export const APPEND_ONLY_TABLES = [
28
+ // Insert-only ledgers: nothing but `INSERT` touches either.
29
+ "hf_audit",
30
+ "hf_activity",
31
+ // Ledger rows are inserted `started` and then `UPDATE`d to a terminal status — including
32
+ // `reconcile()`'s sweep to `abandoned`/`uncertain`, which is still an update.
33
+ "hf_llm_call",
34
+ "hf_action_log",
35
+ // Inserted pending and decided by `UPDATE`; `reconcile()` step (5) expires a stale one the
36
+ // same way. The delete guard exists precisely so an approval outlives the record it names.
37
+ "hf_approval",
38
+ // Inserted, or upserted on `(run_id, key, spec_name)` by a replayed step; never deleted.
39
+ "hf_score",
40
+ // `INSERT` at the start of a source run, `UPDATE` at its end.
41
+ "hf_source_run",
42
+ // `INSERT` at `runs.start`; every later write is an `UPDATE` of status, attempt or the
43
+ // fencing token. Runs are never purged — there is no retention sweep.
44
+ "hf_run",
45
+ ];
46
+ const APPEND_ONLY = new Set(APPEND_ONLY_TABLES);
15
47
  export class RestoreCheckError extends Error {
16
48
  constructor(message) {
17
49
  super(message);
@@ -72,22 +104,27 @@ export async function restoreCheck(options) {
72
104
  `TO ${quoteIdent(names.migratorRole)}`);
73
105
  await scratch.query(`GRANT CREATE, USAGE ON SCHEMA public TO ${quoteIdent(names.migratorRole)}`);
74
106
  await runRestore(options, scratchDatabase, names.migratorRole, dump.path);
75
- rows = compare(await countTables(db, names.databaseName), await countTables(scratch));
107
+ rows = compare(await countTables(db, names.databaseName), await countTables(scratch), options.strict ?? false);
76
108
  }
77
109
  finally {
78
110
  await scratch.close();
79
111
  }
80
- const ok = rows.every((row) => row.verdict === "ok");
81
- if (ok)
112
+ const dumpStale = dumpAgeHours > STALE_DUMP_HOURS;
113
+ const matched = rows.every((row) => row.verdict === "ok");
114
+ // A stale dump keeps the exit code but not the timestamp: the restore itself was proved,
115
+ // and it is `hf doctor` that decides how long a proof stays good.
116
+ if (matched)
82
117
  await options.state.patch({ lastRestoreCheckAt: now.toISOString() });
83
118
  return {
84
119
  databaseName: names.databaseName,
85
120
  scratchDatabase,
86
121
  dump,
87
122
  dumpAgeHours,
88
- dumpStale: dumpAgeHours > STALE_DUMP_HOURS,
123
+ dumpStale,
124
+ strict: options.strict ?? false,
89
125
  rows,
90
- ok,
126
+ matched,
127
+ ok: matched && !dumpStale,
91
128
  };
92
129
  }
93
130
  finally {
@@ -203,23 +240,35 @@ async function countTables(db, database) {
203
240
  * A table on one side only is its own verdict rather than a crash or a zero: an app migration
204
241
  * between the backup and the check is the ordinary reason for it, and reading it as a count of
205
242
  * zero would make an added table look like lost data.
243
+ *
244
+ * An `APPEND_ONLY_TABLES` table the live side is *ahead* on is `ok` with a `drift`, unless
245
+ * `strict`. A restored count above the live one is still a `mismatch` there: the dump cannot
246
+ * hold rows an append-only live table has since lost unless something did lose them.
206
247
  */
207
- function compare(live, restored) {
248
+ function compare(live, restored, strict) {
208
249
  const tables = [...new Set([...live.keys(), ...restored.keys()])].sort();
209
250
  return tables.map((table) => {
210
251
  const liveCount = live.get(table);
211
252
  const restoredCount = restored.get(table);
253
+ const drifted = !strict &&
254
+ APPEND_ONLY.has(table) &&
255
+ liveCount !== undefined &&
256
+ restoredCount !== undefined &&
257
+ restoredCount < liveCount;
212
258
  const verdict = liveCount === undefined
213
259
  ? "restored only"
214
260
  : restoredCount === undefined
215
261
  ? "live only"
216
- : liveCount === restoredCount
262
+ : liveCount === restoredCount || drifted
217
263
  ? "ok"
218
264
  : "mismatch";
219
265
  return {
220
266
  table,
221
267
  ...(liveCount === undefined ? {} : { live: liveCount }),
222
268
  ...(restoredCount === undefined ? {} : { restored: restoredCount }),
269
+ ...(drifted && liveCount !== undefined && restoredCount !== undefined
270
+ ? { drift: liveCount - restoredCount }
271
+ : {}),
223
272
  verdict,
224
273
  };
225
274
  });
@@ -227,24 +276,29 @@ function compare(live, restored) {
227
276
  /** The table `hf restore-check` prints, and the two lines around it. */
228
277
  export function formatRestoreCheck(result) {
229
278
  const age = `${result.dumpAgeHours.toFixed(1)} h old`;
230
- const lines = [`${result.databaseName}: ${result.dump.path}, ${age}`];
279
+ const lines = [
280
+ `${result.databaseName}: ${result.dump.path}, ${age}${result.strict ? ", strict" : ""}`,
281
+ ];
231
282
  if (result.dumpStale) {
232
- lines.push(`WARNING: the dump is ${age} — over ${String(STALE_DUMP_HOURS)} h`);
283
+ lines.push(`WARN: the dump is ${age} — over ${String(STALE_DUMP_HOURS)} h; ` +
284
+ "this compares against stale data");
233
285
  }
234
286
  const header = ["table", "live", "restored", "verdict"];
235
287
  const cells = result.rows.map((row) => [
236
288
  row.table,
237
289
  row.live === undefined ? "—" : String(row.live),
238
290
  row.restored === undefined ? "—" : String(row.restored),
239
- row.verdict,
291
+ row.drift === undefined ? row.verdict : `${row.verdict} (drift +${String(row.drift)})`,
240
292
  ]);
241
293
  const widths = header.map((name, column) => Math.max(name.length, ...cells.map((row) => row[column]?.length ?? 0)));
242
294
  const line = (row) => row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
243
295
  lines.push(line(header), ...cells.map(line));
244
- const mismatched = result.rows.filter((row) => row.verdict !== "ok").length;
245
- lines.push(result.ok
246
- ? `${String(result.rows.length)} table(s) matched`
247
- : `${String(mismatched)} of ${String(result.rows.length)} table(s) did not match`);
296
+ const drifted = result.rows.filter((row) => row.drift !== undefined).length;
297
+ const failed = result.rows.filter((row) => row.verdict !== "ok").length;
298
+ const drift = drifted === 0 ? "" : `, ${String(drifted)} with drift since the dump`;
299
+ lines.push(result.matched
300
+ ? `${String(result.rows.length)} table(s) matched${drift}`
301
+ : `${String(failed)} of ${String(result.rows.length)} table(s) did not match${drift}`);
248
302
  return lines;
249
303
  }
250
304
  /**
@@ -273,6 +327,7 @@ export async function restoreCheckApp(options) {
273
327
  database: db,
274
328
  container: await restoreContainer(runner, db, containers),
275
329
  adminUser: admin.user,
330
+ strict: options.strict,
276
331
  });
277
332
  }
278
333
  finally {
package/dist/roles.d.ts CHANGED
@@ -32,3 +32,4 @@ export declare function credentialsOf(connectionString: string): {
32
32
  user: string;
33
33
  password: string;
34
34
  };
35
+ export declare function quoteLiteral(value: string): string;
package/dist/roles.js CHANGED
@@ -46,6 +46,6 @@ export function credentialsOf(connectionString) {
46
46
  const url = new URL(connectionString);
47
47
  return { user: decodeURIComponent(url.username), password: decodeURIComponent(url.password) };
48
48
  }
49
- function quoteLiteral(value) {
49
+ export function quoteLiteral(value) {
50
50
  return `'${value.replace(/'/g, "''")}'`;
51
51
  }
package/dist/state.d.ts CHANGED
@@ -59,6 +59,14 @@ export interface AppState {
59
59
  * earlier `hf new --local` looks exactly like a lost run.
60
60
  */
61
61
  templateStartedAt?: string;
62
+ /**
63
+ * ISO 8601, written just before the `template` step fetches into the scratch directory beside
64
+ * the app's path.
65
+ *
66
+ * The same proof one step earlier: a scratch directory the state does not vouch for is refused
67
+ * rather than deleted, and a fetch that died half way is this run's own to clear and redo.
68
+ */
69
+ templateFetchStartedAt?: string;
62
70
  /** `owner/name` of the app's GitHub repository. */
63
71
  repo?: string;
64
72
  coolify?: CoolifyState;
package/dist/state.js CHANGED
@@ -150,6 +150,7 @@ function parseAppState(contents, file) {
150
150
  break;
151
151
  case "repo":
152
152
  case "templateStartedAt":
153
+ case "templateFetchStartedAt":
153
154
  case "sentryDsn":
154
155
  case "betterAuthSecret":
155
156
  case "lastRestoreCheckAt":
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This package's own version, read from its `package.json` at runtime.
3
+ *
4
+ * Not a constant the build stamps in: the nine packages are one fixed version group, and a
5
+ * release rewrites `package.json` alone — a baked-in string would be a second copy that is wrong
6
+ * from the next release onwards. `../package.json` resolves the same from `src/` and from `dist/`.
7
+ */
8
+ export declare function cliVersion(): Promise<string>;
@@ -0,0 +1,13 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ /**
4
+ * This package's own version, read from its `package.json` at runtime.
5
+ *
6
+ * Not a constant the build stamps in: the nine packages are one fixed version group, and a
7
+ * release rewrites `package.json` alone — a baked-in string would be a second copy that is wrong
8
+ * from the next release onwards. `../package.json` resolves the same from `src/` and from `dist/`.
9
+ */
10
+ export async function cliVersion() {
11
+ const manifest = JSON.parse(await readFile(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
12
+ return typeof manifest.version === "string" ? manifest.version : "unknown";
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperfixation/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "license": "MIT",
5
5
  "description": "The hf binary and its Turborepo generator templates",
6
6
  "repository": {
@@ -29,15 +29,15 @@
29
29
  "!dist/test-support/**"
30
30
  ],
31
31
  "dependencies": {
32
- "@hyperfixation/auth": "0.1.5",
33
- "@hyperfixation/core": "0.1.5",
34
- "@hyperfixation/db": "0.1.5",
32
+ "@hyperfixation/auth": "0.1.7",
33
+ "@hyperfixation/core": "0.1.7",
34
+ "@hyperfixation/db": "0.1.7",
35
35
  "giget": "3.3.1",
36
36
  "pg": "^8.23.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@hyperfixation/eslint-config": "0.1.5",
40
- "@hyperfixation/testing": "0.1.5",
39
+ "@hyperfixation/eslint-config": "0.1.7",
40
+ "@hyperfixation/testing": "0.1.7",
41
41
  "@microsoft/api-extractor": "^7.59.1",
42
42
  "@types/pg": "^8.23.1",
43
43
  "eslint": "^10.10.0",