Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/test/state-store.test.ts

typescript695 行查看原文
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import type { InputQueue } from "@zpi-dev/ui";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
  STATE_SCHEMA_VERSION,
  StateStore,
  type TaskState,
  validateStateDatabase,
} from "../src/main/state-store.ts";

const paths: string[] = [];
const stores: StateStore[] = [];
function store(path?: string) {
  const root = mkdtempSync(join(tmpdir(), "zpi-state-"));
  paths.push(root);
  const file = path ?? join(root, "state.sqlite");
  const value = new StateStore(file);
  stores.push(value);
  return { value, file };
}
function task(id = "task"): TaskState {
  return { id, projectId: null, title: "Private title sk-secret", createdAt: 1, updatedAt: 2 };
}
const queue = (): InputQueue => ({
  autoDrain: true,
  items: [
    {
      id: "input",
      text: "private prompt",
      fileReferences: [],
      attachments: [],
      selection: { provider: "p", modelId: "m", reasoning: "off" },
      state: "dispatching",
    },
  ],
});
it("creates only the current schema without a migration ledger", () => {
  const { value } = store();
  expect(value.db.prepare("SELECT name FROM sqlite_master WHERE name LIKE 'transcript_%'").all()).toEqual([]);
  expect(value.db.prepare("PRAGMA user_version").get()?.user_version).toBe(STATE_SCHEMA_VERSION);
  expect(
    value.db.prepare("SELECT name FROM sqlite_master WHERE name='schema_migrations'").get(),
  ).toBeUndefined();
});
it.each([1, 2, 3])("rejects historical SQLite schema %s without modifying its bytes", (version) => {
  const { value, file } = store();
  value.db.exec(`PRAGMA user_version=${version}`);
  value.close();
  const before = readFileSync(file);
  expect(() => new StateStore(file)).toThrow("unsupported schema version");
  expect(readFileSync(file)).toEqual(before);
});
afterEach(() => {
  for (const item of stores.splice(0)) {
    try {
      item.close();
    } catch {}
  }
  for (const path of paths.splice(0)) rmSync(path, { recursive: true, force: true });
});
describe("authoritative SQLite state", () => {
  it("keeps appended project order after updates and reopening", () => {
    const { value: s, file } = store();
    const project = (id: string) => ({ id, name: id, path: `/${id}`, updatedAt: 1 });
    s.setProjects([project("z"), project("y")]);
    s.upsertProject(project("a"));
    s.upsertProject({ ...project("z"), name: "renamed" });
    expect(
      store(file)
        .value.listProjects()
        .map((p) => p.id),
    ).toEqual(["z", "y", "a"]);
  });
  it("lists tasks with one query and the same authoritative column overrides as getTask", () => {
    const { value: s } = store();
    s.upsertProject({ id: "project", name: "Project", path: "/work", updatedAt: 1 });
    s.upsertTask({ ...task("older"), updatedAt: 1 });
    s.upsertTask({ ...task("b"), projectId: "project", parentSessionId: "older", sortKey: 3 });
    s.upsertTask({ ...task("a"), projectId: "project", parentSessionId: "older" });
    s.upsertTask({ ...task("deleted"), updatedAt: 10 });
    s.deleteTask("deleted");
    s.db.exec(`INSERT INTO task_groups(id,title,color,created_at) VALUES ('group','Group','blue',1);
      UPDATE tasks SET group_id='group' WHERE id='b';
      UPDATE tasks SET project_id=NULL,parent_task_id=NULL,sort_key=42,revision=9 WHERE id='a';`);
    const expected = [s.getTask("a"), s.getTask("b"), s.getTask("older")];
    const prepare = vi.spyOn(s.db, "prepare");
    try {
      const listed = s.listTasks();
      expect(prepare).toHaveBeenCalledTimes(1);
      expect(listed).toEqual(expected);
      expect(listed.map((item) => item.id)).toEqual(["a", "b", "older"]);
      expect(listed[0]).toMatchObject({
        projectId: null,
        parentSessionId: undefined,
        groupId: null,
        sortKey: 42,
        revision: 9,
      });
      expect(listed[1]).toMatchObject({
        projectId: "project",
        parentSessionId: "older",
        groupId: "group",
        sortKey: 3,
        revision: 1,
      });
    } finally {
      prepare.mockRestore();
    }
  });
  it("reads accepted IDs once while preserving queue order and reserved states", () => {
    const { value: s } = store();
    s.upsertTask(task());
    s.db.prepare("INSERT INTO accepted_inputs VALUES (?,?,?)").run("task", "accepted", "run");
    const input = queue();
    input.items = ["first", "accepted", "last"].map((id) => ({
      ...input.items[0],
      id,
      state: id === "last" ? "dispatching" : "queued",
    }));
    const readAccepted = vi.spyOn(s, "acceptedInputIds");
    s.setQueue("task", input);
    expect(readAccepted).toHaveBeenCalledExactlyOnceWith("task");
    expect(s.getQueue("task")).toEqual({
      ...input,
      items: [input.items[0], input.items[2]],
    });
    expect(s.db.prepare("SELECT id,position,state FROM queue_items ORDER BY position").all()).toEqual([
      { id: "first", position: 0, state: "queued" },
      { id: "last", position: 1, state: "reserved" },
    ]);
    readAccepted.mockClear();
    s.setQueue("task", { autoDrain: true, items: [] });
    expect(readAccepted).not.toHaveBeenCalled();
    expect(s.getQueue("task")).toEqual({ autoDrain: true, items: [] });
  });
  it("reads recovery intents once and reserves only matching task and input pairs", () => {
    const { value: s } = store();
    for (const id of ["task", "other", "empty"]) s.upsertTask(task(id));
    const input = queue();
    input.items = ["prepared", "durable", "blocked", "unreserved"].map((id) => ({
      ...input.items[0],
      id,
    }));
    s.setQueue("task", input);
    s.setQueue("other", { ...queue(), items: [{ ...queue().items[0], id: "other-input" }] });
    s.setQueue("empty", { autoDrain: true, items: [] });
    for (const stage of ["prepared", "durable", "blocked"] as const)
      s.putIntent({
        operationId: stage,
        taskId: "task",
        kind: "accept-input",
        stage,
        targetFile: "task.jsonl",
        queueItemId: stage,
      });
    s.putIntent({
      operationId: "wrong-task",
      taskId: "other",
      kind: "accept-input",
      stage: "prepared",
      targetFile: "other.jsonl",
      queueItemId: "unreserved",
    });
    const readIntents = vi.spyOn(s, "listIntents");
    s.recoverRuntime();
    expect(readIntents).toHaveBeenCalledTimes(1);
    expect(s.getQueue("task")).toEqual({
      ...input,
      autoDrain: false,
      pauseReason: "restart",
      items: input.items.map((item) => ({
        ...item,
        state: item.id === "unreserved" ? "queued" : "dispatching",
      })),
    });
    expect(s.getQueue("other")?.items[0].state).toBe("queued");
    expect(s.getQueue("empty")).toEqual({ autoDrain: true, items: [] });
  });
  it("uses explicit schema, durable WAL and foreign keys", () => {
    const { value: s } = store();
    expect(s.db.prepare("PRAGMA user_version").get()?.user_version).toBe(STATE_SCHEMA_VERSION);
    expect(s.db.prepare("PRAGMA journal_mode").get()?.journal_mode).toBe("wal");
    expect(s.db.prepare("PRAGMA synchronous").get()?.synchronous).toBe(2);
    expect(s.db.prepare("PRAGMA foreign_keys").get()?.foreign_keys).toBe(1);
    expect(() => s.upsertTask({ ...task(), projectId: "missing" })).toThrow();
    expect(s.listTasks()).toEqual([]);
  });
  it("reopens state without a transcript or audit replay", () => {
    const { value: s, file } = store();
    s.upsertProject({ id: "project", name: "Name", path: "/work", updatedAt: 1 });
    s.upsertTask({
      ...task(),
      projectId: "project",
      currentSelection: { provider: "p", modelId: "b", reasoning: "off" },
      running: true,
    });
    s.setSetting("app", { defaultModel: "b" });
    s.saveDraft("task", { revision: 2, text: "draft" } as { revision: number });
    s.saveDraft("task", { revision: 1, text: "stale" } as { revision: number });
    s.db.exec("DELETE FROM operation_log");
    s.checkpoint();
    s.close();
    const reopened = store(file).value;
    expect(reopened.getTask("task")).toMatchObject({
      projectId: "project",
      currentSelection: { modelId: "b" },
    });
    expect(reopened.getTask("task")?.running).toBeUndefined();
    expect(reopened.getSetting("app")).toEqual({ defaultModel: "b" });
    expect(reopened.getDraft("task")).toEqual({ revision: 2, text: "draft" });
  });
  it("atomically rolls state and successful audits back on failure", () => {
    const { value: s } = store();
    s.upsertTask(task());
    expect(() =>
      s.transaction("rename", "task", () => {
        s.upsertTask({ ...task(), title: "changed" });
        s.setSetting("app", { changed: true });
        throw new Error("apiKey=private-secret prompt=private prompt");
      }),
    ).toThrow("private-secret");
    expect(s.getTask("task")?.title).toBe(task().title);
    expect(s.getSetting("app")).toBeUndefined();
    const logs = s.db.prepare("SELECT * FROM operation_log ORDER BY seq").all();
    expect(logs.map((row) => row.outcome)).toEqual(["success", "failure"]);
    expect(JSON.stringify(logs)).not.toMatch(/private-secret|private prompt|Private title|"changed"/);
    expect(logs[0]).toMatchObject({ before_revision: null, after_revision: 1 });
  });
  it("rolls back nested failed work even if the outer transaction catches it", () => {
    const { value: s } = store();
    s.transaction("outer", "task", () => {
      s.upsertTask(task());
      try {
        s.transaction("inner", "task", () => {
          s.setSetting("bad", true);
          throw new Error("fail");
        });
      } catch {}
      s.setSetting("good", true);
    });
    expect(s.getSetting("bad")).toBeUndefined();
    expect(s.getSetting("good")).toBe(true);
  });
  it("stores group membership with stable mixed root ordering", () => {
    const { value: s } = store();
    s.upsertTask(task());
    s.upsertTask(task("two"));
    s.setTaskGroups({
      version: 1,
      groups: [{ id: "group", title: "G", color: "blue", createdAt: 1 }],
      roots: [
        { type: "task", id: "two" },
        { type: "group", id: "group" },
      ],
      members: { group: ["task"] },
    });
    expect(s.getTask("task")).toMatchObject({ groupId: "group", sortKey: 0 });
    expect(s.getTaskGroups().roots).toEqual([
      { type: "task", id: "two" },
      { type: "group", id: "group" },
    ]);
    s.setTaskGroups({
      version: 1,
      groups: [],
      roots: [
        { type: "task", id: "two" },
        { type: "task", id: "task" },
      ],
      members: {},
    });
    expect(s.getTask("task")?.groupId).toBeNull();
    expect(s.getTaskGroups().roots.map((node) => node.id)).toEqual(["two", "task"]);
  });
  it("retains paused queue input and interrupts old runtime ownership without executing", () => {
    const { value: s, file } = store();
    s.upsertTask(task());
    s.setQueue("task", queue());
    s.setRuntime("task", "background", "command", { pid: 1234 });
    s.close();
    const reopened = store(file).value;
    reopened.recoverRuntime();
    expect(reopened.getQueue("task")).toMatchObject({
      autoDrain: false,
      pauseReason: "restart",
      items: [{ id: "input", state: "queued", text: "private prompt" }],
    });
    expect(reopened.listRuntime()[0]).toMatchObject({ status: "interrupted", data: { pid: 1234 } });
    reopened.recoverRuntime();
    expect(reopened.getQueue("task")?.items).toHaveLength(1);
  });
  it("retains recovery intents until final state commits and prevents ID retargeting", () => {
    const { value: s, file } = store();
    s.upsertTask(task());
    const intent = {
      operationId: "op",
      taskId: "task",
      kind: "accept-input" as const,
      stage: "prepared" as const,
      targetFile: "sessions/task.jsonl",
      runId: "run",
      queueItemId: "input",
    };
    s.putIntent(intent);
    s.close();
    const reopened = store(file).value;
    expect(reopened.listIntents()).toEqual([intent]);
    expect(() => reopened.putIntent({ ...intent, targetFile: "other" })).toThrow("identity");
    expect(() =>
      reopened.completeIntent("op", () => {
        reopened.setSetting("test", true);
        throw new Error("crash");
      }),
    ).toThrow();
    expect(reopened.getSetting("test")).toBeUndefined();
    expect(reopened.listIntents()).toHaveLength(1);
    reopened.completeIntent("op", () => reopened.setSetting("test", true));
    expect(reopened.listIntents()).toEqual([]);
  });
  it("accepts queued input once only after a durable fact and atomically finalizes state", () => {
    const { value: s, file } = store();
    s.upsertTask(task());
    s.setQueue("task", queue());
    const intent = {
      operationId: "accept",
      taskId: "task",
      kind: "accept-input" as const,
      stage: "prepared" as const,
      targetFile: "sessions/task.jsonl",
      queueItemId: "input",
      runId: "run",
    };
    s.reserveQueuedInput(intent);
    expect(() => s.completeQueuedInput("accept")).toThrow("durable");
    expect(s.getQueue("task")?.items).toHaveLength(1);
    s.putIntent({ ...intent, stage: "durable", watermark: 123 });
    expect(() =>
      s.completeQueuedInput("accept", () => {
        throw new Error("fault");
      }),
    ).toThrow("fault");
    expect(s.acceptedInputIds("task").size).toBe(0);
    expect(s.getQueue("task")?.items).toHaveLength(1);
    s.completeQueuedInput("accept", () => s.upsertTask({ ...task(), draft: false }));
    expect(s.acceptedInputIds("task")).toEqual(new Set(["input"]));
    expect(s.getQueue("task")?.items).toEqual([]);
    expect(s.listIntents()).toEqual([]);
    s.close();
    const reopened = store(file).value;
    reopened.recoverRuntime();
    expect(reopened.getRuntime("task", "run")[0].status).toBe("interrupted");
    expect(() => reopened.reserveQueuedInput(intent)).toThrow("already accepted");
  });
  it("tombstones deleted tasks and does not resurrect them from other state", () => {
    const { value: s } = store();
    s.upsertTask(task());
    s.saveDraft("task", { revision: 1 });
    s.setQueue("task", queue());
    s.deleteTask("task");
    expect(s.getTask("task")).toBeUndefined();
    expect(s.listTasks()).toEqual([]);
    expect(s.getDraft("task")).toBeUndefined();
    expect(s.getQueue("task")).toBeUndefined();
    expect(s.db.prepare("SELECT deleted_at FROM tasks WHERE id='task'").get()?.deleted_at).toBeTypeOf(
      "number",
    );
  });
  it("rejects credentials in public settings", () => {
    const { value: s } = store();
    for (const key of [
      "apiKey",
      "api_key",
      "password",
      "accessToken",
      "refresh_token",
      "authorization",
      "secret",
    ])
      expect(() => s.setSetting("app", { providers: [{ [key]: "secret" }] })).toThrow("encrypted");
    s.setSetting("provider", { credentialRef: "provider-1" });
    expect(s.getSetting("provider")).toEqual({ credentialRef: "provider-1" });
  });
  it("does not serialize extra intent bodies or retain unbounded audit content", () => {
    const { value: s } = store();
    s.upsertTask(task());
    const intent = {
      operationId: "op",
      taskId: "task",
      kind: "create" as const,
      stage: "prepared" as const,
      targetFile: "task.jsonl",
      prompt: "do not persist",
    };
    s.putIntent(intent);
    expect(JSON.stringify(s.listIntents())).not.toContain("do not persist");
    s.pruneAudit(0, 0, 0);
    expect(s.db.prepare("SELECT COUNT(*) AS count FROM operation_log").get()?.count).toBe(1);
    expect(s.getTask("task")?.title).toBe(task().title);
    expect(s.listIntents()).toHaveLength(1);
  });
  it("rolls malformed group moves back without corrupting membership", () => {
    const { value: s } = store();
    s.upsertTask(task());
    expect(() =>
      s.setTaskGroups({
        version: 1,
        groups: [{ id: "g", title: "G", color: "gray", createdAt: 1 }],
        members: { g: ["task"] },
        roots: [
          { type: "group", id: "g" },
          { type: "task", id: "task" },
        ],
      }),
    ).toThrow("duplicate");
    expect(s.getTask("task")?.groupId).toBeNull();
    expect(s.getTaskGroups().groups).toEqual([]);
  });
  it("refuses unknown versions and unversioned databases instead of guessing state", () => {
    const { value: s, file } = store();
    s.db.exec("PRAGMA user_version=999");
    s.close();
    expect(() => new StateStore(file)).toThrow("unsupported schema");
    const root = mkdtempSync(join(tmpdir(), "zpi-unversioned-"));
    paths.push(root);
    const other = join(root, "state.sqlite");
    const db = new DatabaseSync(other);
    db.exec("CREATE TABLE legacy(x TEXT)");
    db.close();
    expect(() => new StateStore(other)).toThrow("unsupported schema version");
  });
});

it("rejects existing empty databases without modifying their bytes", () => {
  const root = mkdtempSync(join(tmpdir(), "zpi-empty-"));
  paths.push(root);
  for (const name of ["zero.sqlite", "empty.sqlite"]) {
    const file = join(root, name);
    if (name === "zero.sqlite") writeFileSync(file, "");
    else new DatabaseSync(file).close();
    const bytes = readFileSync(file);
    expect(() => new StateStore(file)).toThrow("unsupported schema version");
    expect(readFileSync(file)).toEqual(bytes);
  }
});
it.each(["projects", "drafts", "queue_items", "operation_log", "task_runtime"])(
  "rejects a versioned database missing authoritative table %s without repair",
  (table) => {
    const { value: s, file } = store();
    s.db.exec(`DROP TABLE ${table}`);
    s.close();
    const bytes = readFileSync(file);
    expect(() => new StateStore(file)).toThrow("authoritative schema");
    expect(readFileSync(file)).toEqual(bytes);
  },
);
it("keeps unresolved acceptance reserved across restart and prevents durable intent downgrade", () => {
  const { value: s, file } = store();
  s.upsertTask(task());
  s.setQueue("task", queue());
  const intent = {
    operationId: "pending",
    taskId: "task",
    kind: "accept-input" as const,
    stage: "prepared" as const,
    targetFile: "task.jsonl",
    queueItemId: "input",
    runId: "run",
  };
  s.reserveQueuedInput(intent);
  s.putIntent({ ...intent, stage: "durable", watermark: 42 });
  expect(() => s.putIntent({ ...intent, watermark: 42 })).toThrow("cannot be reset");
  expect(() => s.putIntent({ ...intent, stage: "durable", watermark: 43 })).toThrow("watermark");
  s.close();
  const reopened = store(file).value;
  reopened.recoverRuntime();
  expect(reopened.getQueue("task")).toMatchObject({
    autoDrain: false,
    items: [{ id: "input", state: "dispatching" }],
  });
  expect(reopened.listIntents()).toHaveLength(1);
});

it("rolls business changes back if their success audit cannot commit", () => {
  const { value: s } = store();
  s.upsertTask(task());
  s.db.exec(`CREATE TRIGGER reject_success BEFORE INSERT ON operation_log
    WHEN NEW.outcome='success' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
  expect(() => s.upsertTask({ ...task(), title: "changed" })).toThrow("audit unavailable");
  expect(s.getTask("task")?.title).toBe(task().title);
  expect(s.db.prepare("SELECT outcome FROM operation_log ORDER BY seq DESC LIMIT 1").get()?.outcome).toBe(
    "failure",
  );
});
it("explicit full diagnosis rejects foreign-key corruption without repairing authoritative rows", () => {
  const { value: s, file } = store();
  s.db.exec("PRAGMA foreign_keys=OFF");
  s.upsertTask({ ...task(), projectId: "missing" });
  s.close();
  const bytes = readFileSync(file);
  expect(() => validateStateDatabase(file, "full")).toThrow("integrity");
  expect(readFileSync(file)).toEqual(bytes);
});

it("opens with large settings without any whole-database integrity scan", () => {
  const { value: s, file } = store();
  s.db.exec(`WITH RECURSIVE rows(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM rows WHERE n<20000)
    INSERT INTO app_settings SELECT 'large-'||n,json_object('text',printf('%01024d',n)),1 FROM rows;`);
  s.close();
  const prepare = vi.spyOn(DatabaseSync.prototype, "prepare");
  try {
    const reopened = store(file).value;
    expect(reopened.listTasks()).toEqual([]);
    expect(
      prepare.mock.calls
        .map(([sql]) => sql)
        .filter((sql) =>
          /(?:integrity_check|quick_check|foreign_key_check|FROM transcript_search)/i.test(sql),
        ),
    ).toEqual([]);
  } finally {
    prepare.mockRestore();
  }
});

it("audits renamed, pinned, and archived fields without recording their values", () => {
  const { value: s } = store();
  let current = s.upsertTask(task());
  current = s.upsertTask({ ...current, title: "private renamed title" });
  current = s.upsertTask({ ...current, pinnedAt: 123456 });
  s.upsertTask({ ...current, archivedAt: 654321 });
  const logs = s.db.prepare("SELECT summary FROM operation_log ORDER BY seq").all();
  expect(logs.slice(1).map((row) => JSON.parse(String(row.summary)))).toEqual([
    { changedFields: ["title"] },
    { changedFields: ["pinnedAt"] },
    { changedFields: ["archivedAt"] },
  ]);
  expect(JSON.stringify(logs)).not.toMatch(/private renamed title|123456|654321/);
});
it("audits settings changed field names and rejects credentials without leaking them on failure", () => {
  const { value: s } = store();
  s.setSetting("app", { interface: { theme: "dark" } });
  s.setSetting("app", { interface: { theme: "light" } });
  expect(() =>
    s.setSetting("app", {
      interface: { theme: "light" },
      providers: [{ password: "private-password", apiKey: "private-api-key" }],
    }),
  ).toThrow("encrypted");
  const logs = s.db.prepare("SELECT outcome,summary FROM operation_log ORDER BY seq").all();
  expect(logs.map((row) => ({ outcome: row.outcome, summary: JSON.parse(String(row.summary)) }))).toEqual([
    { outcome: "success", summary: { changedFields: ["interface"] } },
    { outcome: "success", summary: { changedFields: ["interface"] } },
    { outcome: "failure", summary: { changedFields: ["providers"] } },
  ]);
  expect(s.getSetting("app")).toEqual({ interface: { theme: "light" } });
  expect(JSON.stringify(logs)).not.toMatch(/private-password|private-api-key|password|apiKey|light|dark/);
});
it("restricts arbitrary transaction summaries to a bounded fixed field vocabulary", () => {
  const { value: s } = store();
  const summary = {
    changedFields: [
      "title",
      "title",
      "private field name",
      "apiKey",
      "password",
      ...Array(1000).fill("pinnedAt"),
    ],
    prompt: "private prompt",
    password: "private-password",
  };
  expect(() =>
    s.transaction(
      "task.update",
      "task",
      () => {
        throw new Error("private error");
      },
      summary,
    ),
  ).toThrow("private error");
  const log = s.db.prepare("SELECT summary FROM operation_log").get();
  expect(JSON.parse(String(log?.summary))).toEqual({ changedFields: ["pinnedAt", "title"] });
  expect(String(log?.summary).length).toBeLessThan(1024);
});

it.each([
  [null, "data must be an object"],
  [[], "data must be an object"],
  [{}, "id"],
  [{ ...task(), id: "other" }, "id does not match SQL primary key"],
  [{ ...task(), title: undefined }, "title"],
  [{ ...task(), title: 123 }, "title"],
  [{ ...task(), projectId: undefined }, "projectId"],
  [{ ...task(), createdAt: "yesterday" }, "createdAt"],
  [{ ...task(), updatedAt: null }, "updatedAt"],
  [{ ...task(), draft: "false" }, "draft"],
  [{ ...task(), archivedAt: false }, "archivedAt"],
  [{ ...task(), sessionFile: 123 }, "sessionFile"],
  [{ ...task(), worktree: { path: "/work" } }, "worktree"],
  [{ ...task(), currentSelection: {} }, "currentSelection"],
  [{ ...task(), frozenConfiguration: [] }, "frozenConfiguration"],
])("diagnoses invalid task JSON structure without hiding or repairing it: %j", (data, field) => {
  const { value: s } = store();
  s.upsertTask(task());
  s.upsertTask(task("valid"));
  const json = JSON.stringify(data);
  s.db.prepare("UPDATE tasks SET data=? WHERE id='task'").run(json);
  for (const read of [() => s.getTask("task"), () => s.listTasks()]) {
    expect(read).toThrow(`storage: invalid task "task": ${field}`);
  }
  expect(s.db.prepare("SELECT data FROM tasks WHERE id='task'").get()?.data).toBe(json);
  expect(s.getTask("valid")?.id).toBe("valid");
});
it("reads authoritative SQL identity, title, dates and membership over typed legacy JSON mirrors", () => {
  const { value: s } = store();
  s.upsertTask(task());
  s.db.exec(
    "UPDATE tasks SET title='SQL title',created_at=100,updated_at=200,revision=8,sort_key=9 WHERE id='task'",
  );
  expect(s.getTask("task")).toMatchObject({
    id: "task",
    title: "SQL title",
    createdAt: 100,
    updatedAt: 200,
    revision: 8,
    sortKey: 9,
  });
  s.db.exec("UPDATE tasks SET updated_at='invalid' WHERE id='task'");
  expect(() => s.listTasks()).toThrow("updatedAt");
});
it("increments task revisions only for changed persisted task fields, including group/root moves", () => {
  const { value: s } = store();
  s.upsertTask(task());
  s.upsertTask(task("two"));
  const version = () => s.getTask("task")?.revision;
  expect(s.upsertTask(task()).revision).toBe(1);
  expect(s.upsertTask({ ...task(), running: true }).revision).toBe(1);
  expect(s.upsertTask({ ...task(), pinnedAt: undefined, parentSessionId: undefined }).revision).toBe(1);
  const grouped = {
    version: 1 as const,
    groups: [{ id: "g", title: "G", color: "blue" as const, createdAt: 1 }],
    roots: [
      { type: "group" as const, id: "g" },
      { type: "task" as const, id: "two" },
    ],
    members: { g: ["task"] },
  };
  s.setTaskGroups(grouped);
  expect(version()).toBe(2);
  s.setTaskGroups(grouped);
  expect(version()).toBe(2);
  s.setTaskGroups({ ...grouped, groups: [{ ...grouped.groups[0], title: "Renamed" }] });
  expect(version()).toBe(2); // The group title is not a task field.
  const ungrouped = {
    version: 1 as const,
    groups: [],
    members: {},
    roots: [
      { type: "task" as const, id: "task" },
      { type: "task" as const, id: "two" },
    ],
  };
  s.setTaskGroups(ungrouped);
  expect(version()).toBe(3); // Move out even with the same sort key.
  s.setTaskGroups({ ...ungrouped, roots: [...ungrouped.roots].reverse() });
  expect(version()).toBe(4);
  s.setTaskGroups({ ...ungrouped, roots: [...ungrouped.roots].reverse() });
  expect(version()).toBe(4);
  const current = s.getTask("task");
  if (!current) throw new Error("missing task fixture");
  s.upsertTask({ ...current, archivedAt: 100 });
  expect(version()).toBe(5);
  s.deleteTask("task");
  const deletedRevision = s.db.prepare("SELECT revision FROM tasks WHERE id='task'").get()?.revision;
  expect(deletedRevision).toBe(6);
  s.deleteTask("task");
  expect(s.db.prepare("SELECT revision FROM tasks WHERE id='task'").get()?.revision).toBe(6);
  expect(() => s.upsertTask(task())).toThrow("cannot resurrect");
});
it("bumps task revisions when a project is deleted, including archived tasks, without touching unrelated tasks", () => {
  const { value: s } = store();
  s.upsertProject({ id: "p", name: "Project", path: "/work", updatedAt: 1 });
  s.upsertTask({ ...task(), projectId: "p" });
  s.upsertTask({ ...task("archived"), projectId: "p", archivedAt: 1 });
  s.upsertTask(task("unrelated"));
  s.deleteProject("p");
  s.deleteProject("p");
  for (const id of ["task", "archived"])
    expect(s.getTask(id)).toMatchObject({ projectId: null, revision: 2 });
  expect(s.getTask("unrelated")?.revision).toBe(1);
});