@timqi/pier 0.0.4 → 0.0.5

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/README.md CHANGED
@@ -156,13 +156,15 @@ time to finish, whatever the deadline still had to cut off written to the chat
156
156
  it belonged to — and only then hand over. The automatic path additionally waits
157
157
  for an idle instance: no turn streaming, no task run in flight.
158
158
 
159
- Either way the updater writes `~/.pier/db/pier.db.release.bak` before npm
160
- touches the package, updates the npm installation recorded when the service was
161
- installed, and starts Pier again.
162
-
163
- `main` is the only development line. `npm version patch` writes the tag, the
164
- tag builds and publishes a GitHub Release, and the version in the web footer is
165
- the one from `package.json` so the number on screen always names a commit.
159
+ Either way the updater writes `~/.pier/db/backups/pier.db.release-<version>.bak`
160
+ before npm touches the package named for the release being replaced, which is
161
+ the one to reinstall beside it; the three newest are kept. Then it updates the
162
+ npm installation recorded when the service was installed, and starts Pier again.
163
+
164
+ `main` is the only development line. `just release [patch|minor|major]` runs the
165
+ checks, writes the tag and pushes it; the tag builds and publishes to npm and as
166
+ a GitHub Release. The version in the web footer is the one from `package.json`
167
+ — so the number on screen always names a commit.
166
168
  Schema upgrades are one-way: a database migrated by a newer Pier is refused by
167
169
  an older one. The release backup above is the way back; `docs/deploy.md` has the
168
170
  restore procedure and the additional snapshots taken before schema migrations.
package/dist/cli.js CHANGED
@@ -178,7 +178,9 @@ async function signalService(command) {
178
178
  }
179
179
  async function backup() {
180
180
  const [{ backupDb }, { PIER_DB }] = await Promise.all([import("./db.js"), import("./paths.js")]);
181
- const path = backupDb(PIER_DB);
181
+ // This tree's version: the updater runs `backup` before npm replaces it, so
182
+ // it is the release the copy pairs with.
183
+ const path = backupDb(version, PIER_DB);
182
184
  process.stdout.write(path ? `backed up ${path}\n` : `no database yet — nothing to back up.\n`);
183
185
  }
184
186
  function commandPath(name) {
package/dist/db.js CHANGED
@@ -11,15 +11,16 @@
11
11
  // So: one connection, one ordered list of migrations, applied in one
12
12
  // transaction before any store exists. A store receives the handle and owns
13
13
  // only its queries.
14
- import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
14
+ import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
15
15
  import { basename, dirname, join } from "node:path";
16
16
  import { DatabaseSync } from "node:sqlite";
17
17
  import { logger } from "./log.js";
18
18
  import { PIER_DB } from "./paths.js";
19
19
  const log = logger("db");
20
- /** Pre-migration snapshots to keep. Three is two upgrades of regret plus one:
20
+ /** Snapshots to keep *of each kind*. Three is two upgrades of regret plus one:
21
21
  * they are full copies of the database, and the one that matters is the
22
- * newest. */
22
+ * newest. Counted per kind because the two kinds answer different questions —
23
+ * a run of releases must not evict the pre-migration copies. */
23
24
  const KEEP_BACKUPS = 3;
24
25
  /** How long a second process may wait for the write lock before failing. Two
25
26
  * Pier processes on one PIER_HOME contend exactly once — at boot, when both
@@ -147,14 +148,25 @@ let shared;
147
148
  */
148
149
  export const pierDb = () => (shared ??= openDb(PIER_DB));
149
150
  /** A release-level restore point, taken while the service is stopped even when
150
- * the release has no schema migration. The previous complete copy stays put if
151
- * writing its replacement fails. */
152
- export function backupDb(path = PIER_DB) {
151
+ * the release has no schema migration. The previous complete copies stay put if
152
+ * writing this one fails.
153
+ *
154
+ * `version` is the Pier that produced this database, not the one being
155
+ * installed: the updater runs this from the tree it is about to replace, and
156
+ * restoring a database means reinstalling the code that speaks its schema
157
+ * (`migrate` refuses one from a newer Pier). So the name carries the other half
158
+ * of the pair. Backing up twice at one version replaces that version's copy —
159
+ * the pairing is identical, so a second name for it would say nothing. */
160
+ export function backupDb(version, path = PIER_DB) {
153
161
  if (!existsSync(path))
154
162
  return undefined;
155
- const bak = `${path}.release.bak`;
163
+ // In a filename, so it may not carry a separator or a traversal; a version
164
+ // this malformed is a broken install, not something to guess at.
165
+ const safe = version.replaceAll(/[^0-9A-Za-z.+-]/g, "_") || "unknown";
166
+ const bak = join(backupsDir(path, true), `${basename(path)}.release-${safe}.bak`);
156
167
  copyDatabase(path, bak);
157
168
  log.info(`pre-update backup: ${bak}`);
169
+ prune(releases(path));
158
170
  return bak;
159
171
  }
160
172
  /** Open a database, bring it to the current schema, and lock down its files.
@@ -186,9 +198,9 @@ function migrate(db, path, migrations) {
186
198
  if (at > target) {
187
199
  // Name the snapshot that exists rather than a pattern: the operator is
188
200
  // reading this because the service will not start.
189
- const newest = path === ":memory:" ? undefined : backups(path)[0]?.file;
201
+ const newest = path === ":memory:" ? undefined : snapshots(path)[0]?.file;
190
202
  throw new Error(`${path} is at schema ${at}, this Pier speaks ${target}: a database is ` +
191
- `never downgraded. Restore ${newest ?? `${path}.v*.bak`}, or run the newer Pier.`);
203
+ `never downgraded. Restore ${newest ?? `a copy from ${backupsDir(path)}`}, or run the newer Pier.`);
192
204
  }
193
205
  // Version 0 with tables is a database from before versioning existed.
194
206
  // Migration 1 assumes an empty file, so the collision it would hit says
@@ -244,7 +256,7 @@ function migrate(db, path, migrations) {
244
256
  }
245
257
  log.info(locked === 0 ? `schema created at version ${target}` : `schema ${locked} → ${target}`);
246
258
  if (path !== ":memory:")
247
- prune(path);
259
+ prune(snapshots(path).map(({ file }) => file));
248
260
  }
249
261
  /**
250
262
  * The copy that exists because `user_version` only counts up: the transaction
@@ -259,7 +271,7 @@ function migrate(db, path, migrations) {
259
271
  * ever refers to a finished copy.
260
272
  */
261
273
  function snapshot(path, at) {
262
- const bak = `${path}.v${at}.bak`;
274
+ const bak = join(backupsDir(path, true), `${basename(path)}.v${at}.bak`);
263
275
  copyDatabase(path, bak);
264
276
  log.info(`pre-migration backup: ${bak}`);
265
277
  }
@@ -276,22 +288,64 @@ function copyDatabase(path, bak) {
276
288
  chmodSync(tmp, 0o600); // it holds everything the 0600 database holds
277
289
  renameSync(tmp, bak);
278
290
  }
279
- /** Snapshots beside the database, newest schema first. */
280
- function backups(path) {
291
+ /**
292
+ * One directory for every copy of this database, `db/backups/`. Beside the
293
+ * database was fine while there was one snapshot per schema; a restore point
294
+ * per release turns that into a listing where the live file and its sidecars
295
+ * are hard to pick out, and "which of these do I not delete" is the wrong
296
+ * question to make an operator answer under pressure.
297
+ *
298
+ * `create` also adopts what an older Pier wrote next to the database, so the
299
+ * restore procedure names one location instead of two forever.
300
+ */
301
+ function backupsDir(path, create = false) {
302
+ const dir = join(dirname(path), "backups");
303
+ if (!create)
304
+ return dir;
305
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
306
+ const prefix = `${basename(path)}.`;
307
+ for (const name of readdirSync(dirname(path))) {
308
+ if (!name.startsWith(prefix) || !name.endsWith(".bak"))
309
+ continue;
310
+ renameSync(join(dirname(path), name), join(dir, name));
311
+ log.info(`moved ${name} into ${dir}`);
312
+ }
313
+ return dir;
314
+ }
315
+ /** The copies of one kind: `v<schema>` or `release-<version>`. Disjoint
316
+ * prefixes, so each kind is counted and pruned on its own. */
317
+ function listBackups(path, kind) {
318
+ const dir = backupsDir(path);
319
+ if (!existsSync(dir))
320
+ return [];
321
+ const prefix = `${basename(path)}.${kind}`;
322
+ return readdirSync(dir).filter((name) => name.startsWith(prefix) && name.endsWith(".bak"));
323
+ }
324
+ /** Pre-migration snapshots, newest schema first — the number in the name is an
325
+ * ordinal, so it orders them without asking the filesystem. */
326
+ function snapshots(path) {
281
327
  const prefix = `${basename(path)}.v`;
282
- return readdirSync(dirname(path))
283
- .filter((name) => name.startsWith(prefix) && name.endsWith(".bak"))
328
+ return listBackups(path, "v")
284
329
  .map((name) => ({
285
330
  version: Number(name.slice(prefix.length, -".bak".length)),
286
- file: join(dirname(path), name),
331
+ file: join(backupsDir(path), name),
287
332
  }))
288
333
  .filter(({ version }) => Number.isInteger(version))
289
334
  .sort((a, b) => b.version - a.version);
290
335
  }
291
- /** Keep the newest few. Nobody restores a database from four upgrades ago, and
292
- * every one of these is the size of the whole database. */
293
- function prune(path) {
294
- for (const { file } of backups(path).slice(KEEP_BACKUPS)) {
336
+ /** Release restore points, newest copy first. Ordered by mtime: the name holds
337
+ * a Pier version, and comparing those means reimplementing semver here while
338
+ * two updates of one instance are never in flight at the same moment. Legacy
339
+ * `pier.db.release.bak` shares the prefix, so it ages out like the rest. */
340
+ function releases(path) {
341
+ return listBackups(path, "release")
342
+ .map((name) => join(backupsDir(path), name))
343
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
344
+ }
345
+ /** Keep the newest few, oldest first out. Nobody restores a database from four
346
+ * upgrades ago, and every one of these is the size of the whole database. */
347
+ function prune(newestFirst) {
348
+ for (const file of newestFirst.slice(KEEP_BACKUPS)) {
295
349
  rmSync(file, { force: true });
296
350
  log.info(`removed superseded backup: ${file}`);
297
351
  }
@@ -311,4 +365,7 @@ function restrict(path) {
311
365
  chmodSync(file, 0o600);
312
366
  }
313
367
  chmodSync(dirname(path), 0o700);
368
+ // Full copies of the same secrets, one directory down.
369
+ if (existsSync(backupsDir(path)))
370
+ chmodSync(backupsDir(path), 0o700);
314
371
  }
package/dist/service.js CHANGED
@@ -300,7 +300,7 @@ export function startUpdate(options) {
300
300
  if (!run(["systemctl", "--user", "start", "--no-block", UPDATE_UNIT_NAME]))
301
301
  return "failed";
302
302
  say(`updating in the background — follow it with: journalctl --user -u ${UPDATE_UNIT_NAME} -f`);
303
- say(`Pier stops, snapshots pier.db.release.bak, installs, then starts again.`);
303
+ say(`Pier stops, snapshots the database into db/backups/, installs, then starts again.`);
304
304
  return "started";
305
305
  }
306
306
  export function uninstall(home = homedir(), say = console.log, exec) {