@zq-silk/yui 0.6.11 → 0.6.13

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.
@@ -0,0 +1,50 @@
1
+ import { existsSync, renameSync, rmSync } from "node:fs";
2
+ const SQLITE_FILE_SUFFIXES = ["", "-wal", "-shm"];
3
+ /**
4
+ * Move a SQLite database together with any live WAL/SHM sidecars.
5
+ *
6
+ * A WAL database is one logical file set. Moving only the main file can leave
7
+ * the old WAL under the promoted database name, causing SQLite to replay it
8
+ * against an unrelated database. The migration fence guarantees there are no
9
+ * writers while this bounded rename runs.
10
+ */
11
+ export function moveSqliteFileSet(sourcePath, targetPath, rename = renameSync) {
12
+ const suffixes = SQLITE_FILE_SUFFIXES.filter((suffix) => existsSync(`${sourcePath}${suffix}`));
13
+ for (const suffix of suffixes) {
14
+ if (existsSync(`${targetPath}${suffix}`)) {
15
+ throw new Error(`Refusing to overwrite an existing SQLite switch target: ${targetPath}${suffix}.`);
16
+ }
17
+ }
18
+ const moved = [];
19
+ try {
20
+ for (const suffix of suffixes) {
21
+ rename(`${sourcePath}${suffix}`, `${targetPath}${suffix}`);
22
+ moved.push(suffix);
23
+ }
24
+ }
25
+ catch (error) {
26
+ let rollbackError;
27
+ for (const suffix of moved.reverse()) {
28
+ try {
29
+ rename(`${targetPath}${suffix}`, `${sourcePath}${suffix}`);
30
+ }
31
+ catch (candidate) {
32
+ rollbackError ??= candidate;
33
+ }
34
+ }
35
+ if (rollbackError !== undefined) {
36
+ throw new Error(`SQLite file-set move failed and rollback was incomplete: ${messageOf(error)}; `
37
+ + `rollback failed: ${messageOf(rollbackError)}`);
38
+ }
39
+ throw error;
40
+ }
41
+ }
42
+ /** Remove a promoted SQLite file set before restoring its timestamped backup. */
43
+ export function removeSqliteFileSet(databasePath) {
44
+ for (const suffix of SQLITE_FILE_SUFFIXES) {
45
+ rmSync(`${databasePath}${suffix}`, { force: true });
46
+ }
47
+ }
48
+ function messageOf(error) {
49
+ return error instanceof Error ? error.message : String(error);
50
+ }
@@ -42,6 +42,7 @@ import { describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspect
42
42
  import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState } from "./sqliteStateMigration.js";
43
43
  import { latestStateBackupPath } from "./pseudoLayoutRepair.js";
44
44
  import { migrationReceiptPath, writeMigrationReceipt } from "./migrationReceipt.js";
45
+ import { moveSqliteFileSet, removeSqliteFileSet } from "./sqliteFileSet.js";
45
46
  /** Build the SQLite-backed migration target. */
46
47
  export function createSqliteMigrationTarget(options) {
47
48
  const home = options.home;
@@ -155,7 +156,7 @@ export function createSqliteMigrationTarget(options) {
155
156
  if (existsSync(backupPath)) {
156
157
  throw new Error(`Refusing to overwrite an existing database backup: ${backupPath}.`);
157
158
  }
158
- renameSync(committedDbPath, backupPath);
159
+ moveSqliteFileSet(committedDbPath, backupPath);
159
160
  }
160
161
  renameSync(stagedDbPath, committedDbPath);
161
162
  // The staged connection may leave empty WAL/SHM sidecars behind
@@ -167,7 +168,7 @@ export function createSqliteMigrationTarget(options) {
167
168
  // Pre-promotion failure: restore the original database if we moved it.
168
169
  if (backupPath !== undefined && existsSync(backupPath)) {
169
170
  try {
170
- renameSync(backupPath, committedDbPath);
171
+ moveSqliteFileSet(backupPath, committedDbPath);
171
172
  }
172
173
  catch {
173
174
  throw new AmbiguousSwitchError({
@@ -208,10 +209,11 @@ export function createSqliteMigrationTarget(options) {
208
209
  // is ambiguous and must be recovered manually.
209
210
  try {
210
211
  if (backupPath !== undefined && existsSync(backupPath)) {
211
- renameSync(backupPath, committedDbPath);
212
+ removeSqliteFileSet(committedDbPath);
213
+ moveSqliteFileSet(backupPath, committedDbPath);
212
214
  }
213
215
  else {
214
- rmSync(committedDbPath, { force: true });
216
+ removeSqliteFileSet(committedDbPath);
215
217
  }
216
218
  }
217
219
  catch {
@@ -37,6 +37,7 @@ import { planMigration } from "../migration/planner.js";
37
37
  import { AmbiguousSwitchError } from "../migration/index.js";
38
38
  import { describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspectSourceVersionState, inspectSnapshotVersionState } from "./homeMigrationTarget.js";
39
39
  import { writeSwitchProgress } from "./switchProgress.js";
40
+ import { moveSqliteFileSet, removeSqliteFileSet } from "./sqliteFileSet.js";
40
41
  import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, copySqlitePassthroughState, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readStateFromSqlite } from "./sqliteStateMigration.js";
41
42
  /** Build the SQLite-backed record-migration target. */
42
43
  export function createSqliteRecordMigrationTarget(options) {
@@ -135,7 +136,7 @@ export function createSqliteRecordMigrationTarget(options) {
135
136
  if (existsSync(backupPath)) {
136
137
  throw new Error(`Refusing to overwrite an existing database backup: ${backupPath}.`);
137
138
  }
138
- renameSync(committedDbPath, backupPath);
139
+ moveSqliteFileSet(committedDbPath, backupPath);
139
140
  }
140
141
  promoteRename(stagedDbPath, committedDbPath);
141
142
  // The staged connection may leave empty WAL/SHM sidecars behind
@@ -147,7 +148,7 @@ export function createSqliteRecordMigrationTarget(options) {
147
148
  // Pre-promotion failure: restore the original database if we moved it.
148
149
  if (backupPath !== undefined && existsSync(backupPath)) {
149
150
  try {
150
- promoteRename(backupPath, committedDbPath);
151
+ moveSqliteFileSet(backupPath, committedDbPath, promoteRename);
151
152
  }
152
153
  catch {
153
154
  writeInterruptedMarker(home, backupPath, stagedDbPath, now);
@@ -175,10 +176,11 @@ export function createSqliteRecordMigrationTarget(options) {
175
176
  // is ambiguous and must be recovered manually.
176
177
  try {
177
178
  if (backupPath !== undefined && existsSync(backupPath)) {
178
- promoteRename(backupPath, committedDbPath);
179
+ removeSqliteFileSet(committedDbPath);
180
+ moveSqliteFileSet(backupPath, committedDbPath, promoteRename);
179
181
  }
180
182
  else {
181
- rmSync(committedDbPath, { force: true });
183
+ removeSqliteFileSet(committedDbPath);
182
184
  }
183
185
  }
184
186
  catch {
@@ -529,7 +529,6 @@ function hashPayloadTable(db, sql) {
529
529
  const rows = db.prepare(sql).all();
530
530
  return hashRecords(rows.map((row) => JSON.parse(row.payload)));
531
531
  }
532
- /** Reconstruct a WorkMailbox from the normalised mailboxes table columns. */
533
532
  export function rowToMailbox(row) {
534
533
  let target;
535
534
  switch (row.target_kind) {
@@ -551,13 +550,25 @@ export function rowToMailbox(row) {
551
550
  default:
552
551
  target = { kind: row.target_kind };
553
552
  }
554
- return {
555
- schemaVersion: 1,
553
+ const common = {
556
554
  target,
557
555
  nextSequence: row.next_sequence,
558
556
  processing: row.processing === null ? null : JSON.parse(row.processing),
559
557
  pending: row.pending === null ? null : JSON.parse(row.pending)
560
558
  };
559
+ return Object.hasOwn(row, "input_delivery")
560
+ ? {
561
+ schemaVersion: 2,
562
+ ...common,
563
+ inputDelivery: row.input_delivery === null
564
+ ? null
565
+ : JSON.parse(row.input_delivery)
566
+ }
567
+ : { schemaVersion: 1, ...common };
568
+ }
569
+ function readWorkMailboxRows(db) {
570
+ const hasInputDelivery = db.prepare("PRAGMA table_info(mailboxes)").all().some(({ name }) => name === "input_delivery");
571
+ return db.prepare(`SELECT target_kind, task_id, role_name, next_sequence, processing, pending${hasInputDelivery ? ", input_delivery" : ""} FROM mailboxes ORDER BY target_key`).all();
561
572
  }
562
573
  /**
563
574
  * Compute per-family checksums from the SQLite database. The database is
@@ -603,7 +614,7 @@ export function computeDbFamilyChecksums(home, databaseFilename) {
603
614
  checksums.capabilityGrant = hashPayloadTable(db, "SELECT payload FROM capability_grants");
604
615
  checksums.releaseWorkflow = hashPayloadTable(db, "SELECT payload FROM release_workflows");
605
616
  // Mailboxes are reconstructed from typed columns (no payload column).
606
- const mailboxRows = db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes ORDER BY target_key").all();
617
+ const mailboxRows = readWorkMailboxRows(db);
607
618
  checksums.workMailbox = hashRecords(mailboxRows.map(rowToMailbox));
608
619
  return checksums;
609
620
  }
@@ -774,7 +785,7 @@ export function readStateFromSqlite(home) {
774
785
  stored.idHighWaterMarks[row.kind] = row.high_water;
775
786
  }
776
787
  // Work mailboxes: reconstructed from typed columns (no payload column).
777
- const mailboxRows = db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes ORDER BY target_key").all();
788
+ const mailboxRows = readWorkMailboxRows(db);
778
789
  const mailboxes = state.mailboxes;
779
790
  for (const row of mailboxRows) {
780
791
  const mailbox = rowToMailbox(row);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,