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/.github/workflows/release.yml +43 -0
- package/README.md +45 -9
- package/bun.lock +885 -0
- package/dist/index.d.mts +15 -3
- package/dist/index.mjs +56 -29
- package/examples/hello-today.ts +14 -12
- package/package.json +30 -25
- package/src/index.test.ts +578 -411
- package/src/index.ts +390 -295
- package/tsconfig.json +24 -24
package/src/index.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { expect, 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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
458
|
-
|
|
624
|
+
await keyv.set("greeting", "hello");
|
|
625
|
+
expect((await keyv.get("greeting")) as unknown).toBe("hello");
|
|
459
626
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
627
|
+
await keyv.delete("greeting");
|
|
628
|
+
expect((await keyv.get("greeting")) as unknown).toBe(undefined);
|
|
629
|
+
});
|
|
463
630
|
});
|