-
Notifications
You must be signed in to change notification settings - Fork 2
ADE-71: State/sync/config persistence & kvDb hardening: crash-safe writes, migration versioning, and god-file/dedup decomposition #495
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,6 +1,9 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import fs from "node:fs"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import os from "node:os"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import path from "node:path"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { describe, expect, it } from "vitest"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { upsertRecentProject, type GlobalState } from "./globalState"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { readGlobalState, upsertRecentProject, writeGlobalState, type GlobalState } from "./globalState"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe("upsertRecentProject", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it("keeps an existing project in place when preserving recent order", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -62,3 +65,21 @@ describe("upsertRecentProject", () => { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(next.lastProjectRoot).toBe("/projects/a"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe("writeGlobalState", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it("persists state through an atomic temp-file rename", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-global-state-")); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const filePath = path.join(dir, "global-state.json"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const state: GlobalState = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastProjectRoot: "/repo/ade", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| recentProjects: [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { rootPath: "/repo/ade", displayName: "ADE", lastOpenedAt: "2026-05-31T00:00:00.000Z" }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| writeGlobalState(filePath, state); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(readGlobalState(filePath)).toEqual(state); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(fs.readdirSync(dir).filter((entry) => entry.endsWith(".tmp"))).toEqual([]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+69
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test doesn't actually prove the temp-file rename path. A direct overwrite of 🧪 One way to lock down the rename behavior-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
@@
it("persists state through an atomic temp-file rename", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-global-state-"));
const filePath = path.join(dir, "global-state.json");
+ const renameSpy = vi.spyOn(fs, "renameSync");
const state: GlobalState = {
@@
writeGlobalState(filePath, state);
expect(readGlobalState(filePath)).toEqual(state);
+ expect(renameSpy).toHaveBeenCalledTimes(1);
+ const [from, to] = renameSpy.mock.calls[0]!;
+ expect(path.dirname(from)).toBe(dir);
+ expect(from.endsWith(".tmp")).toBe(true);
+ expect(to).toBe(filePath);
expect(fs.readdirSync(dir).filter((entry) => entry.endsWith(".tmp"))).toEqual([]);
+ renameSpy.mockRestore();
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -216,6 +216,17 @@ describe("openKvDb SQL binding", () => { | |
| db.all("select flag from db_value_test where flag = ?", [{} as any]), | ||
| ).toThrow(/Unsupported database value at parameter 1: object .*sql=select flag from db_value_test/i); | ||
| }); | ||
|
|
||
| it("checkpoints pending writes when flushed", async () => { | ||
| const projectRoot = makeProjectRoot("ade-kvdb-flush-"); | ||
| const dbPath = path.join(projectRoot, ".ade", "ade.db"); | ||
| const db = await openKvDb(dbPath, createLogger() as any); | ||
| activeDisposers.push(async () => db.close()); | ||
|
|
||
| db.setJson("flush:probe", { ok: true }); | ||
| expect(() => db.flushNow()).not.toThrow(); | ||
| expect(db.getJson<{ ok: boolean }>("flush:probe")).toEqual({ ok: true }); | ||
| }); | ||
|
Comment on lines
+220
to
+229
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This assertion doesn't prove a checkpoint happened. The same connection can read uncheckpointed WAL data, so this still passes if 🧪 Example of checking the WAL side effect directly db.setJson("flush:probe", { ok: true });
expect(() => db.flushNow()).not.toThrow();
expect(db.getJson<{ ok: boolean }>("flush:probe")).toEqual({ ok: true });
+ const walPath = `${dbPath}-wal`;
+ if (fs.existsSync(walPath)) {
+ expect(fs.statSync(walPath).size).toBe(0);
+ }🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| describe("lane_linear_issue_links schema", () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: arul28/ADE
Length of output: 1052
🏁 Script executed:
Repository: arul28/ADE
Length of output: 272
🌐 Web query:
Zod 4 z.record array input accepts or rejects Array.isArray behavior record schema💡 Result:
In Zod, including in version 4,
z.record()is designed specifically to validate objects (theRecord<K, V>type in TypeScript) and does not inherently treat arrays as valid input [1][2]. If you pass an array to az.record()schema, it will be rejected during validation [1]. While JavaScript arrays are technically objects,z.record()explicitly expects an object structure with keys and values that match the provided schemas [1]. If you need to validate a structure that might be an array, you should usez.array()[1][3][4]. If you need to support both types of input for the same field, you can usez.union([z.array(...), z.record(...)])orz.discriminatedUnion(), depending on your requirements. For context,z.record()is used to validate objects where you define the schema for the keys and the schema for the values (e.g.,z.record(z.string(), z.number())) [1]. It is not a generic collection validator for both arrays and objects [1][2].Citations:
Fix/clarify STRING_MAP_SCHEMA array-handling vs PR summary
z.record(z.string(), z.unknown())is designed for object inputs; passing an array should fail validation (so with.catch(() => undefined)the result should beundefined, not partially accepted numeric keys).z.union([z.array(...), z.record(...)])plus conversion), if that’s the intended behavior..catch(() => undefined)here if you want malformed config fields to be diagnosable rather than silently ignored.🤖 Prompt for AI Agents