Skip to content

Commit 95f4215

Browse files
authored
feat(persistence): add versioned validated data schema
1 parent 7abef3d commit 95f4215

26 files changed

Lines changed: 1954 additions & 135 deletions

scripts/provision-obsidian-e2e-vault.mjs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ const PLUGIN_ID = "podnotes";
2121
// "=> true" so an echoed command can't be mistaken for a positive result.
2222
export const PODNOTES_READY_EVAL = `Boolean(app.plugins.plugins[${JSON.stringify(PLUGIN_ID)}])`;
2323

24-
// A valid, empty PodNotes settings document. Mirrors DEFAULT_SETTINGS in
25-
// src/constants.ts so a freshly provisioned vault loads with clean state instead
26-
// of QuickAdd's { choices, migrations } shape. Keep in sync with constants.ts.
27-
// (currentEpisode is intentionally omitted DEFAULT_SETTINGS sets it to
24+
// A valid, empty PodNotes schema-v1 document. Mirrors DEFAULT_SETTINGS plus the
25+
// persistence marker so a freshly provisioned vault loads with clean state
26+
// instead of QuickAdd's { choices, migrations } shape. Keep in sync with
27+
// constants.ts. (currentEpisode is intentionally omitted - DEFAULT_SETTINGS sets it to
2828
// undefined, which JSON cannot represent and PodNotes treats as absent.)
2929
export const DEFAULT_PODNOTES_DATA = {
30+
schemaVersion: 1,
3031
savedFeeds: {},
3132
podNotes: {},
3233
defaultPlaybackRate: 1,

scripts/provision-obsidian-e2e-vault.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,15 @@ describe("provision-obsidian-e2e-vault", () => {
4949
expect(options.vaultPath).toBe("/tmp/podnotes-repo/vaults/podnotes-a");
5050
});
5151

52-
it("seeds a data.json that mirrors the real DEFAULT_SETTINGS", () => {
52+
it("seeds schema v1 data that mirrors the real DEFAULT_SETTINGS", () => {
5353
// JSON cannot represent currentEpisode: undefined, so compare the
54-
// serialized forms this is exactly what lands in the vault's data.json
54+
// serialized forms - this is exactly what lands in the vault's data.json
5555
// and it fails if a new setting is added to src/constants.ts without
5656
// updating DEFAULT_PODNOTES_DATA.
57-
expect(JSON.parse(JSON.stringify(DEFAULT_PODNOTES_DATA))).toEqual(
58-
JSON.parse(JSON.stringify(DEFAULT_SETTINGS)),
59-
);
57+
expect(JSON.parse(JSON.stringify(DEFAULT_PODNOTES_DATA))).toEqual({
58+
schemaVersion: 1,
59+
...JSON.parse(JSON.stringify(DEFAULT_SETTINGS)),
60+
});
6061
});
6162

6263
it("defaults the vault name to podnotes-<worktree>", () => {

src/TemplateEngine.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,15 @@ describe("empty tag arguments (NT-05/CH-09)", () => {
164164
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
165165
expect(NoteTemplateEngine("{{date:YYYY}}", demoEpisode)).toBe("2024");
166166
});
167+
168+
it("renders a JSON-restored episode date without throwing", () => {
169+
const restoredEpisode = {
170+
...demoEpisode,
171+
episodeDate: "2024-01-01T00:00:00.000Z" as unknown as Date,
172+
};
173+
174+
expect(NoteTemplateEngine("{{date:YYYY-MM-DD}}", restoredEpisode)).toBe("2024-01-01");
175+
});
167176
});
168177

169178
describe("NoteTemplateEngine feed-scoped tags (#163)", () => {

src/main.persistence.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { DEFAULT_SETTINGS } from "./constants";
3+
import PodNotes from "./main";
4+
5+
function makePlugin(
6+
saveData: (data: unknown) => Promise<void> = vi.fn().mockResolvedValue(undefined),
7+
): PodNotes {
8+
const plugin = Object.create(PodNotes.prototype) as PodNotes;
9+
Object.assign(plugin, {
10+
isReady: true,
11+
settings: structuredClone(DEFAULT_SETTINGS),
12+
pendingSave: null,
13+
pendingSaveWaiters: [],
14+
saveScheduled: false,
15+
saveChain: Promise.resolve(),
16+
persistenceUnknownFields: {},
17+
saveData,
18+
});
19+
return plugin;
20+
}
21+
22+
afterEach(() => {
23+
vi.restoreAllMocks();
24+
});
25+
26+
describe("PodNotes persistence integration", () => {
27+
it("loads legacy JSON dates as Date instances", async () => {
28+
const plugin = makePlugin();
29+
Object.assign(plugin, {
30+
loadData: vi.fn().mockResolvedValue({
31+
currentEpisode: {
32+
title: "Restored",
33+
streamUrl: "restored.mp3",
34+
url: "",
35+
description: "",
36+
content: "",
37+
podcastName: "Podcast",
38+
episodeDate: "2024-03-01T10:05:03.000Z",
39+
},
40+
}),
41+
});
42+
43+
await plugin.loadSettings();
44+
45+
expect(plugin.settings.currentEpisode?.episodeDate).toEqual(
46+
new Date("2024-03-01T10:05:03.000Z"),
47+
);
48+
});
49+
50+
it("refuses future data before any save can overwrite it", async () => {
51+
const saveData = vi.fn().mockResolvedValue(undefined);
52+
const plugin = makePlugin(saveData);
53+
Object.assign(plugin, {
54+
loadData: vi.fn().mockResolvedValue({ schemaVersion: 2 }),
55+
});
56+
vi.spyOn(console, "error").mockImplementation(() => undefined);
57+
58+
await expect(plugin.loadSettings()).rejects.toThrow(/schema v2/);
59+
expect(saveData).not.toHaveBeenCalled();
60+
});
61+
62+
it("writes schema v1, canonical dates, and preserved unknown fields", async () => {
63+
const saveData = vi.fn().mockResolvedValue(undefined);
64+
const plugin = makePlugin(saveData);
65+
plugin.settings.currentEpisode = {
66+
title: "Current",
67+
streamUrl: "current.mp3",
68+
url: "",
69+
description: "",
70+
content: "",
71+
podcastName: "Podcast",
72+
episodeDate: new Date("2024-03-01T10:05:03.000Z"),
73+
};
74+
Object.assign(plugin, { persistenceUnknownFields: { retained: { enabled: true } } });
75+
76+
await plugin.saveSettings();
77+
78+
expect(saveData).toHaveBeenCalledWith(
79+
expect.objectContaining({
80+
schemaVersion: 1,
81+
retained: { enabled: true },
82+
currentEpisode: expect.objectContaining({
83+
episodeDate: "2024-03-01T10:05:03.000Z",
84+
}),
85+
}),
86+
);
87+
});
88+
89+
it("rejects a strict caller when saveData fails", async () => {
90+
const failure = new Error("disk full");
91+
const plugin = makePlugin(vi.fn().mockRejectedValue(failure));
92+
vi.spyOn(console, "error").mockImplementation(() => undefined);
93+
94+
await expect(plugin.saveSettingsStrict()).rejects.toBe(failure);
95+
});
96+
97+
it("keeps best-effort saves nonrejecting while logging disk failure", async () => {
98+
const failure = new Error("disk full");
99+
const plugin = makePlugin(vi.fn().mockRejectedValue(failure));
100+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
101+
102+
await expect(plugin.saveSettings()).resolves.toBeUndefined();
103+
expect(consoleError).toHaveBeenCalledWith("PodNotes: failed to save settings", failure);
104+
});
105+
106+
it("normalizes a synchronous snapshot failure into the two save contracts", async () => {
107+
const failure = new Error("cannot clone");
108+
const plugin = makePlugin();
109+
vi.spyOn(globalThis, "structuredClone").mockImplementation(() => {
110+
throw failure;
111+
});
112+
vi.spyOn(console, "error").mockImplementation(() => undefined);
113+
114+
let strict: Promise<void> | undefined;
115+
expect(() => {
116+
strict = plugin.saveSettingsStrict();
117+
}).not.toThrow();
118+
await expect(strict).rejects.toBe(failure);
119+
await expect(plugin.saveSettings()).resolves.toBeUndefined();
120+
});
121+
122+
it("keeps later callers pending until their newer snapshot is durable", async () => {
123+
const resolvers: Array<() => void> = [];
124+
const writes: unknown[] = [];
125+
const plugin = makePlugin(
126+
vi.fn((data: unknown) => {
127+
writes.push(data);
128+
return new Promise<void>((resolve) => resolvers.push(resolve));
129+
}),
130+
);
131+
132+
const first = plugin.saveSettingsStrict();
133+
await vi.waitFor(() => expect(writes).toHaveLength(1));
134+
plugin.settings.defaultVolume = 0.25;
135+
const second = plugin.saveSettingsStrict();
136+
let secondResolved = false;
137+
void second.then(() => {
138+
secondResolved = true;
139+
});
140+
141+
resolvers[0]();
142+
await first;
143+
await vi.waitFor(() => expect(writes).toHaveLength(2));
144+
expect(secondResolved).toBe(false);
145+
expect(writes[1]).toEqual(expect.objectContaining({ defaultVolume: 0.25 }));
146+
147+
resolvers[1]();
148+
await second;
149+
expect(secondResolved).toBe(true);
150+
});
151+
});

0 commit comments

Comments
 (0)