keyv-github 1.0.0 → 1.2.0

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/src/index.test.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { expect, test, describe } from "bun:test";
2
- import { Octokit } from "octokit";
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Octokit } from "octokit";
3
3
  import KeyvGithub, { type KeyvGithubOptions } from "./index.ts";
4
4
 
5
5
  // ── Minimal mock for Octokit REST API ──────────────────────────────────────
@@ -7,457 +7,624 @@ import KeyvGithub, { type KeyvGithubOptions } from "./index.ts";
7
7
  type FileRecord = { content: string; sha: string };
8
8
 
9
9
  function makeMockClient(files: Map<string, FileRecord> = new Map()) {
10
- let shaCounter = 0;
11
- const nextSha = () => `sha${++shaCounter}`;
12
- const messages: string[] = [];
13
-
14
- const getContent = async ({ path, ref: _ref }: { path: string; ref?: string }) => {
15
- const file = files.get(path);
16
- if (!file) {
17
- const err: any = new Error("Not Found");
18
- err.status = 404;
19
- throw err;
20
- }
21
- return {
22
- data: {
23
- type: "file" as const,
24
- content: Buffer.from(file.content).toString("base64"),
25
- sha: file.sha,
26
- name: path.split("/").pop()!,
27
- path,
28
- },
29
- };
30
- };
31
-
32
- const createOrUpdateFileContents = async ({
33
- path,
34
- content,
35
- message,
36
- sha: existingSha,
37
- }: {
38
- path: string;
39
- content: string;
40
- message: string;
41
- sha?: string;
42
- branch?: string;
43
- }) => {
44
- messages.push(message);
45
- const decoded = Buffer.from(content, "base64").toString("utf-8");
46
- const sha = existingSha ?? nextSha();
47
- files.set(path, { content: decoded, sha });
48
- return { data: { content: { path, sha } } };
49
- };
50
-
51
- const deleteFile = async ({
52
- path,
53
- message,
54
- sha,
55
- }: {
56
- path: string;
57
- message: string;
58
- sha: string;
59
- branch?: string;
60
- }) => {
61
- messages.push(message);
62
- const file = files.get(path);
63
- if (!file || file.sha !== sha) {
64
- const err: any = new Error("Not Found");
65
- err.status = 404;
66
- throw err;
67
- }
68
- files.delete(path);
69
- return { data: {} };
70
- };
71
-
72
- // Git Data API – used by _batchCommit, clear, iterator, deleteMany
73
- const getRef = async () => ({ data: { object: { sha: "head-sha" } } });
74
-
75
- const getTree = async (_: { tree_sha: string; recursive?: string }) => {
76
- const blobs = Array.from(files.entries()).map(([path]) => ({
77
- type: "blob" as const,
78
- path,
79
- }));
80
- return { data: { tree: blobs, truncated: false } };
81
- };
82
-
83
- const getCommit = async (_: { commit_sha: string }) => ({
84
- data: { tree: { sha: "base-tree-sha" } },
85
- });
86
-
87
- // Applies inline content / sha:null deletions to the files map
88
- const createTree = async ({ tree }: { base_tree?: string; tree: any[] }) => {
89
- for (const entry of tree) {
90
- if (entry.sha === null) {
91
- files.delete(entry.path);
92
- } else if (entry.content !== undefined) {
93
- files.set(entry.path, { content: entry.content, sha: nextSha() });
94
- }
95
- }
96
- return { data: { sha: `tree-${nextSha()}` } };
97
- };
98
-
99
- const createCommit = async ({ message }: { message: string; tree: string; parents: string[] }) => {
100
- messages.push(message);
101
- return { data: { sha: `commit-${nextSha()}` } };
102
- };
103
-
104
- const updateRef = async (_: { owner: string; repo: string; ref: string; sha: string; force?: boolean }) =>
105
- ({ data: {} });
106
-
107
- return {
108
- rest: {
109
- repos: { getContent, createOrUpdateFileContents, deleteFile },
110
- git: { getRef, getTree, getCommit, createTree, createCommit, updateRef },
111
- },
112
- messages,
113
- };
10
+ let shaCounter = 0;
11
+ const nextSha = () => `sha${++shaCounter}`;
12
+ const messages: string[] = [];
13
+
14
+ const getContent = async ({
15
+ path,
16
+ ref: _ref,
17
+ }: {
18
+ path: string;
19
+ ref?: string;
20
+ }) => {
21
+ const file = files.get(path);
22
+ if (!file) {
23
+ const err: any = new Error("Not Found");
24
+ err.status = 404;
25
+ throw err;
26
+ }
27
+ return {
28
+ data: {
29
+ type: "file" as const,
30
+ content: Buffer.from(file.content).toString("base64"),
31
+ sha: file.sha,
32
+ name: path.split("/").pop()!,
33
+ path,
34
+ },
35
+ };
36
+ };
37
+
38
+ const createOrUpdateFileContents = async ({
39
+ path,
40
+ content,
41
+ message,
42
+ sha: existingSha,
43
+ }: {
44
+ path: string;
45
+ content: string;
46
+ message: string;
47
+ sha?: string;
48
+ branch?: string;
49
+ }) => {
50
+ messages.push(message);
51
+ const decoded = Buffer.from(content, "base64").toString("utf-8");
52
+ const sha = existingSha ?? nextSha();
53
+ files.set(path, { content: decoded, sha });
54
+ return { data: { content: { path, sha } } };
55
+ };
56
+
57
+ const deleteFile = async ({
58
+ path,
59
+ message,
60
+ sha,
61
+ }: {
62
+ path: string;
63
+ message: string;
64
+ sha: string;
65
+ branch?: string;
66
+ }) => {
67
+ messages.push(message);
68
+ const file = files.get(path);
69
+ if (!file || file.sha !== sha) {
70
+ const err: any = new Error("Not Found");
71
+ err.status = 404;
72
+ throw err;
73
+ }
74
+ files.delete(path);
75
+ return { data: {} };
76
+ };
77
+
78
+ // Git Data API – used by _batchCommit, clear, iterator, deleteMany
79
+ const getRef = async () => ({ data: { object: { sha: "head-sha" } } });
80
+
81
+ const getTree = async (_: { tree_sha: string; recursive?: string }) => {
82
+ const blobs = Array.from(files.entries()).map(([path]) => ({
83
+ type: "blob" as const,
84
+ path,
85
+ }));
86
+ return { data: { tree: blobs, truncated: false } };
87
+ };
88
+
89
+ const getCommit = async (_: { commit_sha: string }) => ({
90
+ data: { tree: { sha: "base-tree-sha" } },
91
+ });
92
+
93
+ // Applies inline content / sha:null deletions to the files map
94
+ const createTree = async ({ tree }: { base_tree?: string; tree: any[] }) => {
95
+ for (const entry of tree) {
96
+ if (entry.sha === null) {
97
+ files.delete(entry.path);
98
+ } else if (entry.content !== undefined) {
99
+ files.set(entry.path, { content: entry.content, sha: nextSha() });
100
+ }
101
+ }
102
+ return { data: { sha: `tree-${nextSha()}` } };
103
+ };
104
+
105
+ const createCommit = async ({
106
+ message,
107
+ }: {
108
+ message: string;
109
+ tree: string;
110
+ parents: string[];
111
+ }) => {
112
+ messages.push(message);
113
+ return { data: { sha: `commit-${nextSha()}` } };
114
+ };
115
+
116
+ const updateRef = async (_: {
117
+ owner: string;
118
+ repo: string;
119
+ ref: string;
120
+ sha: string;
121
+ force?: boolean;
122
+ }) => ({ data: {} });
123
+
124
+ return {
125
+ rest: {
126
+ repos: { getContent, createOrUpdateFileContents, deleteFile },
127
+ git: { getRef, getTree, getCommit, createTree, createCommit, updateRef },
128
+ },
129
+ messages,
130
+ };
114
131
  }
115
132
 
116
133
  // ── Helpers ────────────────────────────────────────────────────────────────
117
134
 
118
- function makeStore(files?: Map<string, FileRecord>, options?: Omit<KeyvGithubOptions, "url">) {
119
- const mockFiles = files ?? new Map<string, FileRecord>();
120
- const client = makeMockClient(mockFiles);
121
- const store = new KeyvGithub("https://github.com/owner/repo", { branch: "main", client: client.rest as unknown as Octokit["rest"], ...(options ?? {}) });
122
- return { store, mockFiles, messages: client.messages };
135
+ function makeStore(
136
+ files?: Map<string, FileRecord>,
137
+ options?: Omit<KeyvGithubOptions, "url">,
138
+ ) {
139
+ const mockFiles = files ?? new Map<string, FileRecord>();
140
+ const client = makeMockClient(mockFiles);
141
+ const store = new KeyvGithub("https://github.com/owner/repo", {
142
+ branch: "main",
143
+ client: client.rest as unknown as Octokit["rest"],
144
+ ...(options ?? {}),
145
+ });
146
+ return { store, mockFiles, messages: client.messages };
123
147
  }
124
148
 
125
149
  // ── Tests ──────────────────────────────────────────────────────────────────
126
150
 
127
151
  describe("KeyvGithub constructor", () => {
128
- test("parses HTTPS GitHub URL", () => {
129
- const client = makeMockClient();
130
- expect(
131
- () => new KeyvGithub("https://github.com/owner/repo", { client: client.rest as unknown as Octokit["rest"] })
132
- ).not.toThrow();
133
- });
134
-
135
- test("parses SSH-style GitHub URL", () => {
136
- const client = makeMockClient();
137
- expect(
138
- () => new KeyvGithub("git@github.com:owner/repo.git", { client: client.rest as unknown as Octokit["rest"] })
139
- ).not.toThrow();
140
- });
141
-
142
- test("throws when no owner/repo can be parsed", () => {
143
- expect(() => new KeyvGithub("notarepo")).toThrow("Invalid GitHub repo URL");
144
- });
145
-
146
- test("accepts short owner/repo form without github.com prefix", () => {
147
- const client = makeMockClient();
148
- const store = new KeyvGithub("owner/repo", { client: client.rest as unknown as Octokit["rest"] });
149
- expect(store.owner).toBe("owner");
150
- expect(store.repo).toBe("repo");
151
- });
152
-
153
- test("accepts short owner/repo/tree/branch form", () => {
154
- const client = makeMockClient();
155
- const store = new KeyvGithub("owner/repo/tree/main", { client: client.rest as unknown as Octokit["rest"] });
156
- expect(store.owner).toBe("owner");
157
- expect(store.repo).toBe("repo");
158
- expect(store.branch).toBe("main");
159
- });
160
-
161
- test("parses branch from /tree/<branch> in URL", () => {
162
- const client = makeMockClient();
163
- const store = new KeyvGithub("https://github.com/owner/repo/tree/develop", { client: client.rest as unknown as Octokit["rest"] });
164
- expect(store.branch).toBe("develop");
165
- });
166
-
167
- test("parses multi-segment branch from URL", () => {
168
- const client = makeMockClient();
169
- const store = new KeyvGithub("https://github.com/owner/repo/tree/feature/my-branch", { client: client.rest as unknown as Octokit["rest"] });
170
- expect(store.branch).toBe("feature/my-branch");
171
- });
172
-
173
- test("options.branch overrides URL branch", () => {
174
- const client = makeMockClient();
175
- const store = new KeyvGithub("https://github.com/owner/repo/tree/develop", {
176
- client: client.rest as unknown as Octokit["rest"],
177
- branch: "override",
178
- });
179
- expect(store.branch).toBe("override");
180
- });
181
-
182
- test("defaults to main when no branch in URL or options", () => {
183
- const client = makeMockClient();
184
- const store = new KeyvGithub("https://github.com/owner/repo", { client: client.rest as unknown as Octokit["rest"] });
185
- expect(store.branch).toBe("main");
186
- });
152
+ test("parses HTTPS GitHub URL", () => {
153
+ const client = makeMockClient();
154
+ expect(
155
+ () =>
156
+ new KeyvGithub("https://github.com/owner/repo", {
157
+ client: client.rest as unknown as Octokit["rest"],
158
+ }),
159
+ ).not.toThrow();
160
+ });
161
+
162
+ test("parses SSH-style GitHub URL", () => {
163
+ const client = makeMockClient();
164
+ expect(
165
+ () =>
166
+ new KeyvGithub("git@github.com:owner/repo.git", {
167
+ client: client.rest as unknown as Octokit["rest"],
168
+ }),
169
+ ).not.toThrow();
170
+ });
171
+
172
+ test("throws when no owner/repo can be parsed", () => {
173
+ expect(() => new KeyvGithub("notarepo")).toThrow("Invalid GitHub repo URL");
174
+ });
175
+
176
+ test("accepts short owner/repo form without github.com prefix", () => {
177
+ const client = makeMockClient();
178
+ const store = new KeyvGithub("owner/repo", {
179
+ client: client.rest as unknown as Octokit["rest"],
180
+ });
181
+ expect(store.owner).toBe("owner");
182
+ expect(store.repo).toBe("repo");
183
+ });
184
+
185
+ test("accepts short owner/repo/tree/branch form", () => {
186
+ const client = makeMockClient();
187
+ const store = new KeyvGithub("owner/repo/tree/main", {
188
+ client: client.rest as unknown as Octokit["rest"],
189
+ });
190
+ expect(store.owner).toBe("owner");
191
+ expect(store.repo).toBe("repo");
192
+ expect(store.branch).toBe("main");
193
+ });
194
+
195
+ test("parses branch from /tree/<branch> in URL", () => {
196
+ const client = makeMockClient();
197
+ const store = new KeyvGithub("https://github.com/owner/repo/tree/develop", {
198
+ client: client.rest as unknown as Octokit["rest"],
199
+ });
200
+ expect(store.branch).toBe("develop");
201
+ });
202
+
203
+ test("parses multi-segment branch from URL", () => {
204
+ const client = makeMockClient();
205
+ const store = new KeyvGithub(
206
+ "https://github.com/owner/repo/tree/feature/my-branch",
207
+ {
208
+ client: client.rest as unknown as Octokit["rest"],
209
+ },
210
+ );
211
+ expect(store.branch).toBe("feature/my-branch");
212
+ });
213
+
214
+ test("options.branch overrides URL branch", () => {
215
+ const client = makeMockClient();
216
+ const store = new KeyvGithub("https://github.com/owner/repo/tree/develop", {
217
+ client: client.rest as unknown as Octokit["rest"],
218
+ branch: "override",
219
+ });
220
+ expect(store.branch).toBe("override");
221
+ });
222
+
223
+ test("defaults to main when no branch in URL or options", () => {
224
+ const client = makeMockClient();
225
+ const store = new KeyvGithub("https://github.com/owner/repo", {
226
+ client: client.rest as unknown as Octokit["rest"],
227
+ });
228
+ expect(store.branch).toBe("main");
229
+ });
187
230
  });
188
231
 
189
232
  describe("key validation", () => {
190
- const cases: [string, string][] = [
191
- ["", "empty"],
192
- ["/absolute", "start with '/'"],
193
- ["trailing/", "end with '/'"],
194
- ["double//slash", "contain '//'"],
195
- ["../escape", "'..' segment"],
196
- ["a/./b", "'.' segment"],
197
- ["null\0byte", "null bytes"],
198
- ];
199
-
200
- for (const [bad] of cases) {
201
- test(`throws for ${JSON.stringify(bad)}`, async () => {
202
- const { store } = makeStore();
203
- expect(store.get(bad)).rejects.toThrow();
204
- expect(store.set(bad, "v")).rejects.toThrow();
205
- expect(store.delete(bad)).rejects.toThrow();
206
- expect(store.has(bad)).rejects.toThrow();
207
- });
208
- }
209
-
210
- test("accepts valid paths", async () => {
211
- const { store } = makeStore();
212
- const validPaths = ["simple", "data/file.txt", "a/b/c/d.json", "deep/nested/path"];
213
- for (const path of validPaths) {
214
- expect(await store.get(path)).toBeUndefined();
215
- }
216
- });
233
+ const cases: [string, string][] = [
234
+ ["", "empty"],
235
+ ["/absolute", "start with '/'"],
236
+ ["trailing/", "end with '/'"],
237
+ ["double//slash", "contain '//'"],
238
+ ["../escape", "'..' segment"],
239
+ ["a/./b", "'.' segment"],
240
+ ["null\0byte", "null bytes"],
241
+ ];
242
+
243
+ for (const [bad] of cases) {
244
+ test(`throws for ${JSON.stringify(bad)}`, async () => {
245
+ const { store } = makeStore();
246
+ expect(store.get(bad)).rejects.toThrow();
247
+ expect(store.set(bad, "v")).rejects.toThrow();
248
+ expect(store.delete(bad)).rejects.toThrow();
249
+ expect(store.has(bad)).rejects.toThrow();
250
+ });
251
+ }
252
+
253
+ test("accepts valid paths", async () => {
254
+ const { store } = makeStore();
255
+ const validPaths = [
256
+ "simple",
257
+ "data/file.txt",
258
+ "a/b/c/d.json",
259
+ "deep/nested/path",
260
+ ];
261
+ for (const path of validPaths) {
262
+ expect(await store.get(path)).toBeUndefined();
263
+ }
264
+ });
217
265
  });
218
266
 
219
267
  describe("get", () => {
220
- test("returns undefined for missing key", async () => {
221
- const { store } = makeStore();
222
- expect(await store.get("missing/key")).toBeUndefined();
223
- });
224
-
225
- test("returns file content for existing key", async () => {
226
- const files = new Map([["data/hello", { content: "world", sha: "abc" }]]);
227
- const { store } = makeStore(files);
228
- expect(await store.get<string>("data/hello")).toBe("world");
229
- });
268
+ test("returns undefined for missing key", async () => {
269
+ const { store } = makeStore();
270
+ expect(await store.get("missing/key")).toBeUndefined();
271
+ });
272
+
273
+ test("returns file content for existing key", async () => {
274
+ const files = new Map([["data/hello", { content: "world", sha: "abc" }]]);
275
+ const { store } = makeStore(files);
276
+ expect(await store.get<string>("data/hello")).toBe("world");
277
+ });
230
278
  });
231
279
 
232
280
  describe("set", () => {
233
- test("creates a new file", async () => {
234
- const { store, mockFiles } = makeStore();
235
- await store.set("notes/foo", "bar");
236
- expect(mockFiles.get("notes/foo")?.content).toBe("bar");
237
- });
238
-
239
- test("updates an existing file preserving key", async () => {
240
- const files = new Map([["notes/foo", { content: "old", sha: "sha1" }]]);
241
- const { store, mockFiles } = makeStore(files);
242
- await store.set("notes/foo", "new");
243
- expect(mockFiles.get("notes/foo")?.content).toBe("new");
244
- });
245
-
246
- test("stores arbitrary unicode values", async () => {
247
- const { store, mockFiles } = makeStore();
248
- await store.set("unicode", "日本語テスト 🎉");
249
- expect(mockFiles.get("unicode")?.content).toBe("日本語テスト 🎉");
250
- });
281
+ test("creates a new file", async () => {
282
+ const { store, mockFiles } = makeStore();
283
+ await store.set("notes/foo", "bar");
284
+ expect(mockFiles.get("notes/foo")?.content).toBe("bar");
285
+ });
286
+
287
+ test("updates an existing file preserving key", async () => {
288
+ const files = new Map([["notes/foo", { content: "old", sha: "sha1" }]]);
289
+ const { store, mockFiles } = makeStore(files);
290
+ await store.set("notes/foo", "new");
291
+ expect(mockFiles.get("notes/foo")?.content).toBe("new");
292
+ });
293
+
294
+ test("stores arbitrary unicode values", async () => {
295
+ const { store, mockFiles } = makeStore();
296
+ await store.set("unicode", "日本語テスト 🎉");
297
+ expect(mockFiles.get("unicode")?.content).toBe("日本語テスト 🎉");
298
+ });
299
+
300
+ test("throws when TTL is provided (not supported natively)", async () => {
301
+ const { store } = makeStore();
302
+ expect(store.set("key", "value", 1000)).rejects.toThrow(
303
+ "TTL is not supported natively",
304
+ );
305
+ });
306
+
307
+ test("throws when value is not a string", async () => {
308
+ const { store } = makeStore();
309
+ expect(store.set("key", { obj: true })).rejects.toThrow(
310
+ "only supports string values",
311
+ );
312
+ expect(store.set("key", 123)).rejects.toThrow(
313
+ "only supports string values",
314
+ );
315
+ expect(store.set("key", ["array"])).rejects.toThrow(
316
+ "only supports string values",
317
+ );
318
+ });
251
319
  });
252
320
 
253
321
  describe("delete", () => {
254
- test("returns false for missing key", async () => {
255
- const { store } = makeStore();
256
- expect(await store.delete("nope")).toBe(false);
257
- });
258
-
259
- test("deletes existing key and returns true", async () => {
260
- const files = new Map([["remove/me", { content: "v", sha: "s1" }]]);
261
- const { store, mockFiles } = makeStore(files);
262
- const result = await store.delete("remove/me");
263
- expect(result).toBe(true);
264
- expect(mockFiles.has("remove/me")).toBe(false);
265
- });
322
+ test("returns false for missing key", async () => {
323
+ const { store } = makeStore();
324
+ expect(await store.delete("nope")).toBe(false);
325
+ });
326
+
327
+ test("deletes existing key and returns true", async () => {
328
+ const files = new Map([["remove/me", { content: "v", sha: "s1" }]]);
329
+ const { store, mockFiles } = makeStore(files);
330
+ const result = await store.delete("remove/me");
331
+ expect(result).toBe(true);
332
+ expect(mockFiles.has("remove/me")).toBe(false);
333
+ });
266
334
  });
267
335
 
268
336
  describe("has", () => {
269
- test("returns false when key does not exist", async () => {
270
- const { store } = makeStore();
271
- expect(await store.has("ghost")).toBe(false);
272
- });
273
-
274
- test("returns true when key exists", async () => {
275
- const files = new Map([["present", { content: "yes", sha: "s" }]]);
276
- const { store } = makeStore(files);
277
- expect(await store.has("present")).toBe(true);
278
- });
337
+ test("returns false when key does not exist", async () => {
338
+ const { store } = makeStore();
339
+ expect(await store.has("ghost")).toBe(false);
340
+ });
341
+
342
+ test("returns true when key exists", async () => {
343
+ const files = new Map([["present", { content: "yes", sha: "s" }]]);
344
+ const { store } = makeStore(files);
345
+ expect(await store.has("present")).toBe(true);
346
+ });
279
347
  });
280
348
 
281
349
  describe("clear", () => {
282
- test("throws by default (enableClear not set)", async () => {
283
- const { store } = makeStore();
284
- expect(store.clear()).rejects.toThrow("enableClear");
285
- });
286
-
287
- test("throws when enableClear is false", async () => {
288
- const { store } = makeStore(undefined, { enableClear: false });
289
- expect(store.clear()).rejects.toThrow("enableClear");
290
- });
291
-
292
- test("removes all files when enableClear is true", async () => {
293
- const files = new Map<string, FileRecord>([
294
- ["a", { content: "1", sha: "s1" }],
295
- ["b", { content: "2", sha: "s2" }],
296
- ["c/d", { content: "3", sha: "s3" }],
297
- ]);
298
- const { store, mockFiles } = makeStore(files, { enableClear: true });
299
- await store.clear();
300
- expect(mockFiles.size).toBe(0);
301
- });
302
-
303
- test("no-op on empty store when enableClear is true", async () => {
304
- const { store } = makeStore(undefined, { enableClear: true });
305
- await store.clear(); // should not throw
306
- });
350
+ test("throws by default (enableClear not set)", async () => {
351
+ const { store } = makeStore();
352
+ expect(store.clear()).rejects.toThrow("enableClear");
353
+ });
354
+
355
+ test("throws when enableClear is false", async () => {
356
+ const { store } = makeStore(undefined, { enableClear: false });
357
+ expect(store.clear()).rejects.toThrow("enableClear");
358
+ });
359
+
360
+ test("removes all files when enableClear is true", async () => {
361
+ const files = new Map<string, FileRecord>([
362
+ ["a", { content: "1", sha: "s1" }],
363
+ ["b", { content: "2", sha: "s2" }],
364
+ ["c/d", { content: "3", sha: "s3" }],
365
+ ]);
366
+ const { store, mockFiles } = makeStore(files, { enableClear: true });
367
+ await store.clear();
368
+ expect(mockFiles.size).toBe(0);
369
+ });
370
+
371
+ test("no-op on empty store when enableClear is true", async () => {
372
+ const { store } = makeStore(undefined, { enableClear: true });
373
+ await store.clear(); // should not throw
374
+ });
307
375
  });
308
376
 
309
377
  describe("setMany", () => {
310
- test("writes multiple files in one batch", async () => {
311
- const { store, mockFiles, messages } = makeStore();
312
- await store.setMany([
313
- { key: "a/1.txt", value: "hello" },
314
- { key: "b/2.txt", value: "world" },
315
- { key: "c/3.txt", value: "!" },
316
- ]);
317
- expect(mockFiles.get("a/1.txt")?.content).toBe("hello");
318
- expect(mockFiles.get("b/2.txt")?.content).toBe("world");
319
- expect(mockFiles.get("c/3.txt")?.content).toBe("!");
320
- expect(messages).toHaveLength(1); // single commit
321
- expect(messages[0]).toBe("batch update 3 files");
322
- });
323
-
324
- test("uses msg hook for single-entry batch", async () => {
325
- const { store, messages } = makeStore(undefined, {
326
- msg: (key: string, value: string | null) => `custom: ${key}=${value}`,
327
- });
328
- await store.setMany([{ key: "x/y", value: "v" }]);
329
- expect(messages[0]).toBe("custom: x/y=v");
330
- });
331
-
332
- test("no-op for empty array", async () => {
333
- const { store, messages } = makeStore();
334
- await store.setMany([]);
335
- expect(messages).toHaveLength(0);
336
- });
337
-
338
- test("validates all keys before writing", async () => {
339
- const { store, mockFiles } = makeStore();
340
- expect(store.setMany([{ key: "good/key", value: "v" }, { key: "../bad", value: "v" }])).rejects.toThrow();
341
- expect(mockFiles.size).toBe(0);
342
- });
378
+ test("writes multiple files in one batch", async () => {
379
+ const { store, mockFiles, messages } = makeStore();
380
+ await store.setMany([
381
+ { key: "a/1.txt", value: "hello" },
382
+ { key: "b/2.txt", value: "world" },
383
+ { key: "c/3.txt", value: "!" },
384
+ ]);
385
+ expect(mockFiles.get("a/1.txt")?.content).toBe("hello");
386
+ expect(mockFiles.get("b/2.txt")?.content).toBe("world");
387
+ expect(mockFiles.get("c/3.txt")?.content).toBe("!");
388
+ expect(messages).toHaveLength(1); // single commit
389
+ expect(messages[0]).toBe("batch update 3 files");
390
+ });
391
+
392
+ test("uses msg hook for single-entry batch", async () => {
393
+ const { store, messages } = makeStore(undefined, {
394
+ msg: (key: string, value: string | null) => `custom: ${key}=${value}`,
395
+ });
396
+ await store.setMany([{ key: "x/y", value: "v" }]);
397
+ expect(messages[0]).toBe("custom: x/y=v");
398
+ });
399
+
400
+ test("no-op for empty array", async () => {
401
+ const { store, messages } = makeStore();
402
+ await store.setMany([]);
403
+ expect(messages).toHaveLength(0);
404
+ });
405
+
406
+ test("validates all keys before writing", async () => {
407
+ const { store, mockFiles } = makeStore();
408
+ expect(
409
+ store.setMany([
410
+ { key: "good/key", value: "v" },
411
+ { key: "../bad", value: "v" },
412
+ ]),
413
+ ).rejects.toThrow();
414
+ expect(mockFiles.size).toBe(0);
415
+ });
416
+
417
+ test("throws when any value is not a string", async () => {
418
+ const { store, mockFiles } = makeStore();
419
+ expect(
420
+ store.setMany([
421
+ { key: "a", value: "ok" },
422
+ { key: "b", value: { obj: true } },
423
+ ]),
424
+ ).rejects.toThrow("only supports string values");
425
+ expect(mockFiles.size).toBe(0);
426
+ });
343
427
  });
344
428
 
345
429
  describe("deleteMany", () => {
346
- test("deletes multiple existing files in one batch, returns true", async () => {
347
- const files = new Map<string, FileRecord>([
348
- ["a", { content: "1", sha: "s1" }],
349
- ["b", { content: "2", sha: "s2" }],
350
- ["c", { content: "3", sha: "s3" }],
351
- ]);
352
- const { store, mockFiles, messages } = makeStore(files);
353
- const result = await store.deleteMany(["a", "b"]);
354
- expect(result).toBe(true);
355
- expect(mockFiles.has("a")).toBe(false);
356
- expect(mockFiles.has("b")).toBe(false);
357
- expect(mockFiles.has("c")).toBe(true);
358
- expect(messages).toHaveLength(1); // single commit
359
- expect(messages[0]).toBe("batch delete 2 files");
360
- });
361
-
362
- test("returns false when no keys exist", async () => {
363
- const { store } = makeStore();
364
- const result = await store.deleteMany(["missing"]);
365
- expect(result).toBe(false);
366
- });
367
-
368
- test("returns false for empty array", async () => {
369
- const { store, messages } = makeStore();
370
- const result = await store.deleteMany([]);
371
- expect(result).toBe(false);
372
- expect(messages).toHaveLength(0);
373
- });
374
-
375
- test("uses msg hook for single-key batch", async () => {
376
- const files = new Map([["k", { content: "v", sha: "s" }]]);
377
- const { store, messages } = makeStore(files, {
378
- msg: (key: string, value: string | null) => `rm: ${key} (${value ?? "null"})`,
379
- });
380
- await store.deleteMany(["k"]);
381
- expect(messages[0]).toBe("rm: k (null)");
382
- });
430
+ test("deletes multiple existing files in one batch, returns true", async () => {
431
+ const files = new Map<string, FileRecord>([
432
+ ["a", { content: "1", sha: "s1" }],
433
+ ["b", { content: "2", sha: "s2" }],
434
+ ["c", { content: "3", sha: "s3" }],
435
+ ]);
436
+ const { store, mockFiles, messages } = makeStore(files);
437
+ const result = await store.deleteMany(["a", "b"]);
438
+ expect(result).toBe(true);
439
+ expect(mockFiles.has("a")).toBe(false);
440
+ expect(mockFiles.has("b")).toBe(false);
441
+ expect(mockFiles.has("c")).toBe(true);
442
+ expect(messages).toHaveLength(1); // single commit
443
+ expect(messages[0]).toBe("batch delete 2 files");
444
+ });
445
+
446
+ test("returns false when no keys exist", async () => {
447
+ const { store } = makeStore();
448
+ const result = await store.deleteMany(["missing"]);
449
+ expect(result).toBe(false);
450
+ });
451
+
452
+ test("returns false for empty array", async () => {
453
+ const { store, messages } = makeStore();
454
+ const result = await store.deleteMany([]);
455
+ expect(result).toBe(false);
456
+ expect(messages).toHaveLength(0);
457
+ });
458
+
459
+ test("uses msg hook for single-key batch", async () => {
460
+ const files = new Map([["k", { content: "v", sha: "s" }]]);
461
+ const { store, messages } = makeStore(files, {
462
+ msg: (key: string, value: string | null) =>
463
+ `rm: ${key} (${value ?? "null"})`,
464
+ });
465
+ await store.deleteMany(["k"]);
466
+ expect(messages[0]).toBe("rm: k (null)");
467
+ });
383
468
  });
384
469
 
385
470
  describe("iterator", () => {
386
- test("yields all key-value pairs", async () => {
387
- const files = new Map<string, FileRecord>([
388
- ["x", { content: "1", sha: "s1" }],
389
- ["y/z", { content: "2", sha: "s2" }],
390
- ]);
391
- const { store } = makeStore(files);
392
- const results: [string, unknown][] = [];
393
- for await (const entry of store.iterator()) {
394
- results.push(entry);
395
- }
396
- expect(results.length).toBe(2);
397
- expect(results.find(([k]) => k === "x")?.[1]).toBe("1");
398
- expect(results.find(([k]) => k === "y/z")?.[1]).toBe("2");
399
- });
400
-
401
- test("filters by prefix when provided", async () => {
402
- const files = new Map<string, FileRecord>([
403
- ["ns/a", { content: "1", sha: "s1" }],
404
- ["ns/b", { content: "2", sha: "s2" }],
405
- ["other/c", { content: "3", sha: "s3" }],
406
- ]);
407
- const { store } = makeStore(files);
408
- const results: [string, unknown][] = [];
409
- for await (const entry of store.iterator("ns/")) {
410
- results.push(entry);
411
- }
412
- expect(results.length).toBe(2);
413
- expect(results.every(([k]) => k.startsWith("ns/"))).toBe(true);
414
- });
471
+ test("yields all key-value pairs", async () => {
472
+ const files = new Map<string, FileRecord>([
473
+ ["x", { content: "1", sha: "s1" }],
474
+ ["y/z", { content: "2", sha: "s2" }],
475
+ ]);
476
+ const { store } = makeStore(files);
477
+ const results: [string, unknown][] = [];
478
+ for await (const entry of store.iterator()) {
479
+ results.push(entry);
480
+ }
481
+ expect(results.length).toBe(2);
482
+ expect(results.find(([k]) => k === "x")?.[1]).toBe("1");
483
+ expect(results.find(([k]) => k === "y/z")?.[1]).toBe("2");
484
+ });
485
+
486
+ test("filters by prefix when provided", async () => {
487
+ const files = new Map<string, FileRecord>([
488
+ ["ns/a", { content: "1", sha: "s1" }],
489
+ ["ns/b", { content: "2", sha: "s2" }],
490
+ ["other/c", { content: "3", sha: "s3" }],
491
+ ]);
492
+ const { store } = makeStore(files);
493
+ const results: [string, unknown][] = [];
494
+ for await (const entry of store.iterator("ns/")) {
495
+ results.push(entry);
496
+ }
497
+ expect(results.length).toBe(2);
498
+ expect(results.every(([k]) => k.startsWith("ns/"))).toBe(true);
499
+ });
415
500
  });
416
501
 
417
502
  describe("msg hook", () => {
418
- test("custom msg is called with key and value on set", async () => {
419
- const calls: [string, string | null][] = [];
420
- const { store, messages } = makeStore(undefined, {
421
- msg: (key: string, value: string | null) => { calls.push([key, value]); return `chore: put ${key}`; },
422
- });
423
- await store.set("notes/foo", "bar");
424
- expect(calls).toEqual([["notes/foo", "bar"]]);
425
- expect(messages[messages.length - 1]).toBe("chore: put notes/foo");
426
- });
427
-
428
- test("custom msg is called with key and null on delete", async () => {
429
- const calls: [string, string | null][] = [];
430
- const files = new Map([["to/remove", { content: "v", sha: "s1" }]]);
431
- const { store, messages } = makeStore(files, {
432
- msg: (key: string, value: string | null) => { calls.push([key, value]); return `chore: rm ${key}`; },
433
- });
434
- await store.delete("to/remove");
435
- expect(calls).toEqual([["to/remove", null]]);
436
- expect(messages[messages.length - 1]).toBe("chore: rm to/remove");
437
- });
438
-
439
- test("default msg falls back to 'update <key>' / 'delete <key>'", async () => {
440
- const { store, messages } = makeStore();
441
- await store.set("k", "v");
442
- expect(messages[messages.length - 1]).toBe("update k");
443
-
444
- const files2 = new Map([["k2", { content: "v", sha: "s1" }]]);
445
- const { store: store2, messages: messages2 } = makeStore(files2);
446
- await store2.delete("k2");
447
- expect(messages2[messages2.length - 1]).toBe("delete k2");
448
- });
503
+ test("custom msg is called with key and value on set", async () => {
504
+ const calls: [string, string | null][] = [];
505
+ const { store, messages } = makeStore(undefined, {
506
+ msg: (key: string, value: string | null) => {
507
+ calls.push([key, value]);
508
+ return `chore: put ${key}`;
509
+ },
510
+ });
511
+ await store.set("notes/foo", "bar");
512
+ expect(calls).toEqual([["notes/foo", "bar"]]);
513
+ expect(messages[messages.length - 1]).toBe("chore: put notes/foo");
514
+ });
515
+
516
+ test("custom msg is called with key and null on delete", async () => {
517
+ const calls: [string, string | null][] = [];
518
+ const files = new Map([["to/remove", { content: "v", sha: "s1" }]]);
519
+ const { store, messages } = makeStore(files, {
520
+ msg: (key: string, value: string | null) => {
521
+ calls.push([key, value]);
522
+ return `chore: rm ${key}`;
523
+ },
524
+ });
525
+ await store.delete("to/remove");
526
+ expect(calls).toEqual([["to/remove", null]]);
527
+ expect(messages[messages.length - 1]).toBe("chore: rm to/remove");
528
+ });
529
+
530
+ test("default msg falls back to 'update <key>' / 'delete <key>'", async () => {
531
+ const { store, messages } = makeStore();
532
+ await store.set("k", "v");
533
+ expect(messages[messages.length - 1]).toBe("update k");
534
+
535
+ const files2 = new Map([["k2", { content: "v", sha: "s1" }]]);
536
+ const { store: store2, messages: messages2 } = makeStore(files2);
537
+ await store2.delete("k2");
538
+ expect(messages2[messages2.length - 1]).toBe("delete k2");
539
+ });
540
+ });
541
+
542
+ describe("prefix and suffix options", () => {
543
+ test("get uses prefix+key+suffix as path", async () => {
544
+ const files = new Map([
545
+ ["data/hello.json", { content: "world", sha: "abc" }],
546
+ ]);
547
+ const { store } = makeStore(files, { prefix: "data/", suffix: ".json" });
548
+ expect(await store.get<string>("hello")).toBe("world");
549
+ });
550
+
551
+ test("set writes to prefix+key+suffix path", async () => {
552
+ const { store, mockFiles } = makeStore(undefined, {
553
+ prefix: "data/",
554
+ suffix: ".json",
555
+ });
556
+ await store.set("foo", "bar");
557
+ expect(mockFiles.get("data/foo.json")?.content).toBe("bar");
558
+ expect(mockFiles.has("foo")).toBe(false);
559
+ });
560
+
561
+ test("delete removes prefix+key+suffix path", async () => {
562
+ const files = new Map([["data/foo.json", { content: "v", sha: "s1" }]]);
563
+ const { store, mockFiles } = makeStore(files, {
564
+ prefix: "data/",
565
+ suffix: ".json",
566
+ });
567
+ const result = await store.delete("foo");
568
+ expect(result).toBe(true);
569
+ expect(mockFiles.has("data/foo.json")).toBe(false);
570
+ });
571
+
572
+ test("has checks prefix+key+suffix path", async () => {
573
+ const files = new Map([["data/foo.json", { content: "v", sha: "s1" }]]);
574
+ const { store } = makeStore(files, { prefix: "data/", suffix: ".json" });
575
+ expect(await store.has("foo")).toBe(true);
576
+ expect(await store.has("bar")).toBe(false);
577
+ });
578
+
579
+ test("iterator yields middle key and filters by prefix/suffix", async () => {
580
+ const files = new Map<string, FileRecord>([
581
+ ["data/a.json", { content: "1", sha: "s1" }],
582
+ ["data/b.json", { content: "2", sha: "s2" }],
583
+ ["other/c.json", { content: "3", sha: "s3" }],
584
+ ["data/d.txt", { content: "4", sha: "s4" }],
585
+ ]);
586
+ const { store } = makeStore(files, { prefix: "data/", suffix: ".json" });
587
+ const results: [string, unknown][] = [];
588
+ for await (const entry of store.iterator()) {
589
+ results.push(entry);
590
+ }
591
+ expect(results.length).toBe(2);
592
+ expect(results.find(([k]) => k === "a")?.[1]).toBe("1");
593
+ expect(results.find(([k]) => k === "b")?.[1]).toBe("2");
594
+ });
595
+
596
+ test("clear only removes files matching prefix/suffix", async () => {
597
+ const files = new Map<string, FileRecord>([
598
+ ["data/a.json", { content: "1", sha: "s1" }],
599
+ ["other/b.txt", { content: "2", sha: "s2" }],
600
+ ]);
601
+ const { store, mockFiles } = makeStore(files, {
602
+ prefix: "data/",
603
+ suffix: ".json",
604
+ enableClear: true,
605
+ });
606
+ await store.clear();
607
+ expect(mockFiles.has("data/a.json")).toBe(false);
608
+ expect(mockFiles.has("other/b.txt")).toBe(true);
609
+ });
610
+
611
+ test("defaults to empty prefix and suffix (path === key)", async () => {
612
+ const { store, mockFiles } = makeStore();
613
+ await store.set("plain", "value");
614
+ expect(mockFiles.get("plain")?.content).toBe("value");
615
+ });
449
616
  });
450
617
 
451
618
  describe("integration with Keyv", () => {
452
- test("works as a Keyv storage adapter", async () => {
453
- const { default: Keyv } = await import("keyv");
454
- const { store } = makeStore();
455
- const keyv = new Keyv({ store });
619
+ test("works as a Keyv storage adapter", async () => {
620
+ const { default: Keyv } = await import("keyv");
621
+ const { store } = makeStore();
622
+ const keyv = new Keyv({ store });
456
623
 
457
- await keyv.set("greeting", "hello");
458
- expect(await keyv.get("greeting") as unknown).toBe("hello");
624
+ await keyv.set("greeting", "hello");
625
+ expect((await keyv.get("greeting")) as unknown).toBe("hello");
459
626
 
460
- await keyv.delete("greeting");
461
- expect(await keyv.get("greeting") as unknown).toBe(undefined);
462
- });
627
+ await keyv.delete("greeting");
628
+ expect((await keyv.get("greeting")) as unknown).toBe(undefined);
629
+ });
463
630
  });