keyv-github 1.0.0 → 1.1.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 +1 -1
- package/bun.lock +885 -0
- package/dist/index.d.mts +14 -1
- package/dist/index.mjs +50 -28
- package/examples/hello-today.ts +14 -12
- package/package.json +30 -25
- package/src/index.test.ts +545 -411
- package/src/index.ts +372 -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,591 @@ 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
|
+
{ client: client.rest as unknown as Octokit["rest"] },
|
|
208
|
+
);
|
|
209
|
+
expect(store.branch).toBe("feature/my-branch");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("options.branch overrides URL branch", () => {
|
|
213
|
+
const client = makeMockClient();
|
|
214
|
+
const store = new KeyvGithub("https://github.com/owner/repo/tree/develop", {
|
|
215
|
+
client: client.rest as unknown as Octokit["rest"],
|
|
216
|
+
branch: "override",
|
|
217
|
+
});
|
|
218
|
+
expect(store.branch).toBe("override");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("defaults to main when no branch in URL or options", () => {
|
|
222
|
+
const client = makeMockClient();
|
|
223
|
+
const store = new KeyvGithub("https://github.com/owner/repo", {
|
|
224
|
+
client: client.rest as unknown as Octokit["rest"],
|
|
225
|
+
});
|
|
226
|
+
expect(store.branch).toBe("main");
|
|
227
|
+
});
|
|
187
228
|
});
|
|
188
229
|
|
|
189
230
|
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
|
-
|
|
231
|
+
const cases: [string, string][] = [
|
|
232
|
+
["", "empty"],
|
|
233
|
+
["/absolute", "start with '/'"],
|
|
234
|
+
["trailing/", "end with '/'"],
|
|
235
|
+
["double//slash", "contain '//'"],
|
|
236
|
+
["../escape", "'..' segment"],
|
|
237
|
+
["a/./b", "'.' segment"],
|
|
238
|
+
["null\0byte", "null bytes"],
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
for (const [bad] of cases) {
|
|
242
|
+
test(`throws for ${JSON.stringify(bad)}`, async () => {
|
|
243
|
+
const { store } = makeStore();
|
|
244
|
+
expect(store.get(bad)).rejects.toThrow();
|
|
245
|
+
expect(store.set(bad, "v")).rejects.toThrow();
|
|
246
|
+
expect(store.delete(bad)).rejects.toThrow();
|
|
247
|
+
expect(store.has(bad)).rejects.toThrow();
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
test("accepts valid paths", async () => {
|
|
252
|
+
const { store } = makeStore();
|
|
253
|
+
const validPaths = [
|
|
254
|
+
"simple",
|
|
255
|
+
"data/file.txt",
|
|
256
|
+
"a/b/c/d.json",
|
|
257
|
+
"deep/nested/path",
|
|
258
|
+
];
|
|
259
|
+
for (const path of validPaths) {
|
|
260
|
+
expect(await store.get(path)).toBeUndefined();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
217
263
|
});
|
|
218
264
|
|
|
219
265
|
describe("get", () => {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
266
|
+
test("returns undefined for missing key", async () => {
|
|
267
|
+
const { store } = makeStore();
|
|
268
|
+
expect(await store.get("missing/key")).toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("returns file content for existing key", async () => {
|
|
272
|
+
const files = new Map([["data/hello", { content: "world", sha: "abc" }]]);
|
|
273
|
+
const { store } = makeStore(files);
|
|
274
|
+
expect(await store.get<string>("data/hello")).toBe("world");
|
|
275
|
+
});
|
|
230
276
|
});
|
|
231
277
|
|
|
232
278
|
describe("set", () => {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
279
|
+
test("creates a new file", async () => {
|
|
280
|
+
const { store, mockFiles } = makeStore();
|
|
281
|
+
await store.set("notes/foo", "bar");
|
|
282
|
+
expect(mockFiles.get("notes/foo")?.content).toBe("bar");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("updates an existing file preserving key", async () => {
|
|
286
|
+
const files = new Map([["notes/foo", { content: "old", sha: "sha1" }]]);
|
|
287
|
+
const { store, mockFiles } = makeStore(files);
|
|
288
|
+
await store.set("notes/foo", "new");
|
|
289
|
+
expect(mockFiles.get("notes/foo")?.content).toBe("new");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("stores arbitrary unicode values", async () => {
|
|
293
|
+
const { store, mockFiles } = makeStore();
|
|
294
|
+
await store.set("unicode", "日本語テスト 🎉");
|
|
295
|
+
expect(mockFiles.get("unicode")?.content).toBe("日本語テスト 🎉");
|
|
296
|
+
});
|
|
251
297
|
});
|
|
252
298
|
|
|
253
299
|
describe("delete", () => {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
300
|
+
test("returns false for missing key", async () => {
|
|
301
|
+
const { store } = makeStore();
|
|
302
|
+
expect(await store.delete("nope")).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("deletes existing key and returns true", async () => {
|
|
306
|
+
const files = new Map([["remove/me", { content: "v", sha: "s1" }]]);
|
|
307
|
+
const { store, mockFiles } = makeStore(files);
|
|
308
|
+
const result = await store.delete("remove/me");
|
|
309
|
+
expect(result).toBe(true);
|
|
310
|
+
expect(mockFiles.has("remove/me")).toBe(false);
|
|
311
|
+
});
|
|
266
312
|
});
|
|
267
313
|
|
|
268
314
|
describe("has", () => {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
315
|
+
test("returns false when key does not exist", async () => {
|
|
316
|
+
const { store } = makeStore();
|
|
317
|
+
expect(await store.has("ghost")).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("returns true when key exists", async () => {
|
|
321
|
+
const files = new Map([["present", { content: "yes", sha: "s" }]]);
|
|
322
|
+
const { store } = makeStore(files);
|
|
323
|
+
expect(await store.has("present")).toBe(true);
|
|
324
|
+
});
|
|
279
325
|
});
|
|
280
326
|
|
|
281
327
|
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
|
-
|
|
328
|
+
test("throws by default (enableClear not set)", async () => {
|
|
329
|
+
const { store } = makeStore();
|
|
330
|
+
expect(store.clear()).rejects.toThrow("enableClear");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("throws when enableClear is false", async () => {
|
|
334
|
+
const { store } = makeStore(undefined, { enableClear: false });
|
|
335
|
+
expect(store.clear()).rejects.toThrow("enableClear");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("removes all files when enableClear is true", async () => {
|
|
339
|
+
const files = new Map<string, FileRecord>([
|
|
340
|
+
["a", { content: "1", sha: "s1" }],
|
|
341
|
+
["b", { content: "2", sha: "s2" }],
|
|
342
|
+
["c/d", { content: "3", sha: "s3" }],
|
|
343
|
+
]);
|
|
344
|
+
const { store, mockFiles } = makeStore(files, { enableClear: true });
|
|
345
|
+
await store.clear();
|
|
346
|
+
expect(mockFiles.size).toBe(0);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("no-op on empty store when enableClear is true", async () => {
|
|
350
|
+
const { store } = makeStore(undefined, { enableClear: true });
|
|
351
|
+
await store.clear(); // should not throw
|
|
352
|
+
});
|
|
307
353
|
});
|
|
308
354
|
|
|
309
355
|
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
|
-
|
|
356
|
+
test("writes multiple files in one batch", async () => {
|
|
357
|
+
const { store, mockFiles, messages } = makeStore();
|
|
358
|
+
await store.setMany([
|
|
359
|
+
{ key: "a/1.txt", value: "hello" },
|
|
360
|
+
{ key: "b/2.txt", value: "world" },
|
|
361
|
+
{ key: "c/3.txt", value: "!" },
|
|
362
|
+
]);
|
|
363
|
+
expect(mockFiles.get("a/1.txt")?.content).toBe("hello");
|
|
364
|
+
expect(mockFiles.get("b/2.txt")?.content).toBe("world");
|
|
365
|
+
expect(mockFiles.get("c/3.txt")?.content).toBe("!");
|
|
366
|
+
expect(messages).toHaveLength(1); // single commit
|
|
367
|
+
expect(messages[0]).toBe("batch update 3 files");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("uses msg hook for single-entry batch", async () => {
|
|
371
|
+
const { store, messages } = makeStore(undefined, {
|
|
372
|
+
msg: (key: string, value: string | null) => `custom: ${key}=${value}`,
|
|
373
|
+
});
|
|
374
|
+
await store.setMany([{ key: "x/y", value: "v" }]);
|
|
375
|
+
expect(messages[0]).toBe("custom: x/y=v");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("no-op for empty array", async () => {
|
|
379
|
+
const { store, messages } = makeStore();
|
|
380
|
+
await store.setMany([]);
|
|
381
|
+
expect(messages).toHaveLength(0);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("validates all keys before writing", async () => {
|
|
385
|
+
const { store, mockFiles } = makeStore();
|
|
386
|
+
expect(
|
|
387
|
+
store.setMany([
|
|
388
|
+
{ key: "good/key", value: "v" },
|
|
389
|
+
{ key: "../bad", value: "v" },
|
|
390
|
+
]),
|
|
391
|
+
).rejects.toThrow();
|
|
392
|
+
expect(mockFiles.size).toBe(0);
|
|
393
|
+
});
|
|
343
394
|
});
|
|
344
395
|
|
|
345
396
|
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
|
-
|
|
397
|
+
test("deletes multiple existing files in one batch, returns true", async () => {
|
|
398
|
+
const files = new Map<string, FileRecord>([
|
|
399
|
+
["a", { content: "1", sha: "s1" }],
|
|
400
|
+
["b", { content: "2", sha: "s2" }],
|
|
401
|
+
["c", { content: "3", sha: "s3" }],
|
|
402
|
+
]);
|
|
403
|
+
const { store, mockFiles, messages } = makeStore(files);
|
|
404
|
+
const result = await store.deleteMany(["a", "b"]);
|
|
405
|
+
expect(result).toBe(true);
|
|
406
|
+
expect(mockFiles.has("a")).toBe(false);
|
|
407
|
+
expect(mockFiles.has("b")).toBe(false);
|
|
408
|
+
expect(mockFiles.has("c")).toBe(true);
|
|
409
|
+
expect(messages).toHaveLength(1); // single commit
|
|
410
|
+
expect(messages[0]).toBe("batch delete 2 files");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("returns false when no keys exist", async () => {
|
|
414
|
+
const { store } = makeStore();
|
|
415
|
+
const result = await store.deleteMany(["missing"]);
|
|
416
|
+
expect(result).toBe(false);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("returns false for empty array", async () => {
|
|
420
|
+
const { store, messages } = makeStore();
|
|
421
|
+
const result = await store.deleteMany([]);
|
|
422
|
+
expect(result).toBe(false);
|
|
423
|
+
expect(messages).toHaveLength(0);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("uses msg hook for single-key batch", async () => {
|
|
427
|
+
const files = new Map([["k", { content: "v", sha: "s" }]]);
|
|
428
|
+
const { store, messages } = makeStore(files, {
|
|
429
|
+
msg: (key: string, value: string | null) =>
|
|
430
|
+
`rm: ${key} (${value ?? "null"})`,
|
|
431
|
+
});
|
|
432
|
+
await store.deleteMany(["k"]);
|
|
433
|
+
expect(messages[0]).toBe("rm: k (null)");
|
|
434
|
+
});
|
|
383
435
|
});
|
|
384
436
|
|
|
385
437
|
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
|
-
|
|
438
|
+
test("yields all key-value pairs", async () => {
|
|
439
|
+
const files = new Map<string, FileRecord>([
|
|
440
|
+
["x", { content: "1", sha: "s1" }],
|
|
441
|
+
["y/z", { content: "2", sha: "s2" }],
|
|
442
|
+
]);
|
|
443
|
+
const { store } = makeStore(files);
|
|
444
|
+
const results: [string, unknown][] = [];
|
|
445
|
+
for await (const entry of store.iterator()) {
|
|
446
|
+
results.push(entry);
|
|
447
|
+
}
|
|
448
|
+
expect(results.length).toBe(2);
|
|
449
|
+
expect(results.find(([k]) => k === "x")?.[1]).toBe("1");
|
|
450
|
+
expect(results.find(([k]) => k === "y/z")?.[1]).toBe("2");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("filters by prefix when provided", async () => {
|
|
454
|
+
const files = new Map<string, FileRecord>([
|
|
455
|
+
["ns/a", { content: "1", sha: "s1" }],
|
|
456
|
+
["ns/b", { content: "2", sha: "s2" }],
|
|
457
|
+
["other/c", { content: "3", sha: "s3" }],
|
|
458
|
+
]);
|
|
459
|
+
const { store } = makeStore(files);
|
|
460
|
+
const results: [string, unknown][] = [];
|
|
461
|
+
for await (const entry of store.iterator("ns/")) {
|
|
462
|
+
results.push(entry);
|
|
463
|
+
}
|
|
464
|
+
expect(results.length).toBe(2);
|
|
465
|
+
expect(results.every(([k]) => k.startsWith("ns/"))).toBe(true);
|
|
466
|
+
});
|
|
415
467
|
});
|
|
416
468
|
|
|
417
469
|
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
|
-
|
|
470
|
+
test("custom msg is called with key and value on set", async () => {
|
|
471
|
+
const calls: [string, string | null][] = [];
|
|
472
|
+
const { store, messages } = makeStore(undefined, {
|
|
473
|
+
msg: (key: string, value: string | null) => {
|
|
474
|
+
calls.push([key, value]);
|
|
475
|
+
return `chore: put ${key}`;
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
await store.set("notes/foo", "bar");
|
|
479
|
+
expect(calls).toEqual([["notes/foo", "bar"]]);
|
|
480
|
+
expect(messages[messages.length - 1]).toBe("chore: put notes/foo");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("custom msg is called with key and null on delete", async () => {
|
|
484
|
+
const calls: [string, string | null][] = [];
|
|
485
|
+
const files = new Map([["to/remove", { content: "v", sha: "s1" }]]);
|
|
486
|
+
const { store, messages } = makeStore(files, {
|
|
487
|
+
msg: (key: string, value: string | null) => {
|
|
488
|
+
calls.push([key, value]);
|
|
489
|
+
return `chore: rm ${key}`;
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
await store.delete("to/remove");
|
|
493
|
+
expect(calls).toEqual([["to/remove", null]]);
|
|
494
|
+
expect(messages[messages.length - 1]).toBe("chore: rm to/remove");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("default msg falls back to 'update <key>' / 'delete <key>'", async () => {
|
|
498
|
+
const { store, messages } = makeStore();
|
|
499
|
+
await store.set("k", "v");
|
|
500
|
+
expect(messages[messages.length - 1]).toBe("update k");
|
|
501
|
+
|
|
502
|
+
const files2 = new Map([["k2", { content: "v", sha: "s1" }]]);
|
|
503
|
+
const { store: store2, messages: messages2 } = makeStore(files2);
|
|
504
|
+
await store2.delete("k2");
|
|
505
|
+
expect(messages2[messages2.length - 1]).toBe("delete k2");
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe("prefix and suffix options", () => {
|
|
510
|
+
test("get uses prefix+key+suffix as path", async () => {
|
|
511
|
+
const files = new Map([
|
|
512
|
+
["data/hello.json", { content: "world", sha: "abc" }],
|
|
513
|
+
]);
|
|
514
|
+
const { store } = makeStore(files, { prefix: "data/", suffix: ".json" });
|
|
515
|
+
expect(await store.get<string>("hello")).toBe("world");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("set writes to prefix+key+suffix path", async () => {
|
|
519
|
+
const { store, mockFiles } = makeStore(undefined, {
|
|
520
|
+
prefix: "data/",
|
|
521
|
+
suffix: ".json",
|
|
522
|
+
});
|
|
523
|
+
await store.set("foo", "bar");
|
|
524
|
+
expect(mockFiles.get("data/foo.json")?.content).toBe("bar");
|
|
525
|
+
expect(mockFiles.has("foo")).toBe(false);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("delete removes prefix+key+suffix path", async () => {
|
|
529
|
+
const files = new Map([["data/foo.json", { content: "v", sha: "s1" }]]);
|
|
530
|
+
const { store, mockFiles } = makeStore(files, {
|
|
531
|
+
prefix: "data/",
|
|
532
|
+
suffix: ".json",
|
|
533
|
+
});
|
|
534
|
+
const result = await store.delete("foo");
|
|
535
|
+
expect(result).toBe(true);
|
|
536
|
+
expect(mockFiles.has("data/foo.json")).toBe(false);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("has checks prefix+key+suffix path", async () => {
|
|
540
|
+
const files = new Map([["data/foo.json", { content: "v", sha: "s1" }]]);
|
|
541
|
+
const { store } = makeStore(files, { prefix: "data/", suffix: ".json" });
|
|
542
|
+
expect(await store.has("foo")).toBe(true);
|
|
543
|
+
expect(await store.has("bar")).toBe(false);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("iterator yields middle key and filters by prefix/suffix", async () => {
|
|
547
|
+
const files = new Map<string, FileRecord>([
|
|
548
|
+
["data/a.json", { content: "1", sha: "s1" }],
|
|
549
|
+
["data/b.json", { content: "2", sha: "s2" }],
|
|
550
|
+
["other/c.json", { content: "3", sha: "s3" }],
|
|
551
|
+
["data/d.txt", { content: "4", sha: "s4" }],
|
|
552
|
+
]);
|
|
553
|
+
const { store } = makeStore(files, { prefix: "data/", suffix: ".json" });
|
|
554
|
+
const results: [string, unknown][] = [];
|
|
555
|
+
for await (const entry of store.iterator()) {
|
|
556
|
+
results.push(entry);
|
|
557
|
+
}
|
|
558
|
+
expect(results.length).toBe(2);
|
|
559
|
+
expect(results.find(([k]) => k === "a")?.[1]).toBe("1");
|
|
560
|
+
expect(results.find(([k]) => k === "b")?.[1]).toBe("2");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("clear only removes files matching prefix/suffix", async () => {
|
|
564
|
+
const files = new Map<string, FileRecord>([
|
|
565
|
+
["data/a.json", { content: "1", sha: "s1" }],
|
|
566
|
+
["other/b.txt", { content: "2", sha: "s2" }],
|
|
567
|
+
]);
|
|
568
|
+
const { store, mockFiles } = makeStore(files, {
|
|
569
|
+
prefix: "data/",
|
|
570
|
+
suffix: ".json",
|
|
571
|
+
enableClear: true,
|
|
572
|
+
});
|
|
573
|
+
await store.clear();
|
|
574
|
+
expect(mockFiles.has("data/a.json")).toBe(false);
|
|
575
|
+
expect(mockFiles.has("other/b.txt")).toBe(true);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("defaults to empty prefix and suffix (path === key)", async () => {
|
|
579
|
+
const { store, mockFiles } = makeStore();
|
|
580
|
+
await store.set("plain", "value");
|
|
581
|
+
expect(mockFiles.get("plain")?.content).toBe("value");
|
|
582
|
+
});
|
|
449
583
|
});
|
|
450
584
|
|
|
451
585
|
describe("integration with Keyv", () => {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
586
|
+
test("works as a Keyv storage adapter", async () => {
|
|
587
|
+
const { default: Keyv } = await import("keyv");
|
|
588
|
+
const { store } = makeStore();
|
|
589
|
+
const keyv = new Keyv({ store });
|
|
456
590
|
|
|
457
|
-
|
|
458
|
-
|
|
591
|
+
await keyv.set("greeting", "hello");
|
|
592
|
+
expect((await keyv.get("greeting")) as unknown).toBe("hello");
|
|
459
593
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
594
|
+
await keyv.delete("greeting");
|
|
595
|
+
expect((await keyv.get("greeting")) as unknown).toBe(undefined);
|
|
596
|
+
});
|
|
463
597
|
});
|