keyv-github 1.0.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.ts ADDED
@@ -0,0 +1,308 @@
1
+ import { EventEmitter } from "events";
2
+ import { Octokit } from "octokit";
3
+ import type { KeyvStoreAdapter, StoredData } from "keyv";
4
+
5
+ export interface KeyvGithubOptions {
6
+ url: string;
7
+ branch?: string;
8
+ client?: Octokit | Octokit["rest"];
9
+ /** Customize the commit message. value is null for deletes. */
10
+ msg?: (key: string, value: string | null) => string;
11
+ /** clear() deletes every file in the repo and is disabled by default. Set to true to allow it. */
12
+ enableClear?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Keyv storage adapter backed by a GitHub repository.
17
+ *
18
+ * Each key is a file path in the repo; the file content is the value.
19
+ * Example: new KeyvGithub("https://github.com/owner/repo/tree/main", { client })
20
+ */
21
+ export default class KeyvGithub extends EventEmitter implements KeyvStoreAdapter {
22
+ opts: KeyvGithubOptions;
23
+ namespace?: string;
24
+
25
+ readonly owner: string;
26
+ readonly repo: string;
27
+ readonly ref: string;
28
+ /** Alias for {@link ref}. */
29
+ get branch(): string { return this.ref; }
30
+ rest: Octokit["rest"];
31
+ private msg: (key: string, value: string | null) => string;
32
+ readonly enableClear: boolean;
33
+
34
+ constructor(url: string, options: Omit<KeyvGithubOptions, "url"> = {}) {
35
+ super();
36
+ // github.com prefix is optional: "owner/repo/tree/branch" works too
37
+ // RegExp string avoids native-TS-preview parser issues with char classes containing ? or #
38
+ const match = url.match(new RegExp("(?:.*github\\.com[/:])?([^/:]+)/([^/]+?)(?:\\.git)?(?:/tree/([^?#]+))?(?:[?#].*)?$"));
39
+ if (!match) throw new Error(`Invalid GitHub repo URL: ${url}`);
40
+ this.owner = match[1]!;
41
+ this.repo = match[2]!;
42
+ this.ref = options.branch ?? match[3] ?? "main";
43
+ this.opts = { url, ...options };
44
+ this.rest = options.client instanceof Octokit ? options.client.rest : options.client ?? new Octokit().rest;
45
+ this.msg = options.msg ?? ((key, value) => value === null ? `delete ${key}` : `update ${key}`);
46
+ this.enableClear = options.enableClear ?? false;
47
+ }
48
+
49
+ private static isHttpError(e: unknown): e is { status: number } {
50
+ return typeof e === "object" && e !== null && "status" in e && typeof (e as Record<string, unknown>).status === "number";
51
+ }
52
+
53
+ private validateKey(key: string): void {
54
+ if (!key) throw new Error("Key must not be empty");
55
+ if (key.startsWith("/")) throw new Error(`Key must not start with '/': ${key}`);
56
+ if (key.endsWith("/")) throw new Error(`Key must not end with '/': ${key}`);
57
+ if (key.includes("//")) throw new Error(`Key must not contain '//': ${key}`);
58
+ if (key.includes("\0")) throw new Error(`Key must not contain null bytes: ${key}`);
59
+ if (key.split("/").some((seg) => seg === ".." || seg === "."))
60
+ throw new Error(`Key must not contain '.' or '..' segments: ${key}`);
61
+ }
62
+
63
+ async get<Value>(key: string): Promise<StoredData<Value> | undefined> {
64
+ this.validateKey(key);
65
+ try {
66
+ const { data } = await this.rest.repos.getContent({
67
+ owner: this.owner,
68
+ repo: this.repo,
69
+ path: key,
70
+ ref: this.ref,
71
+ });
72
+ if (Array.isArray(data) || data.type !== "file") return undefined;
73
+ return Buffer.from(data.content, "base64").toString("utf-8") as StoredData<Value>;
74
+ } catch (e: unknown) {
75
+ if (KeyvGithub.isHttpError(e) && e.status === 404) return undefined;
76
+ throw e;
77
+ }
78
+ }
79
+
80
+ async set(key: string, value: any, _ttl?: number): Promise<void> {
81
+ this.validateKey(key);
82
+ let sha: string | undefined;
83
+ try {
84
+ const { data } = await this.rest.repos.getContent({
85
+ owner: this.owner,
86
+ repo: this.repo,
87
+ path: key,
88
+ ref: this.ref,
89
+ });
90
+ if (!Array.isArray(data) && data.type === "file") sha = data.sha;
91
+ } catch (e: unknown) {
92
+ if (!KeyvGithub.isHttpError(e) || e.status !== 404) throw e;
93
+ }
94
+
95
+ await this.rest.repos.createOrUpdateFileContents({
96
+ owner: this.owner,
97
+ repo: this.repo,
98
+ path: key,
99
+ message: this.msg(key, value),
100
+ content: Buffer.from(String(value)).toString("base64"),
101
+ sha,
102
+ branch: this.ref,
103
+ });
104
+ }
105
+
106
+ async delete(key: string): Promise<boolean> {
107
+ this.validateKey(key);
108
+ try {
109
+ const { data } = await this.rest.repos.getContent({
110
+ owner: this.owner,
111
+ repo: this.repo,
112
+ path: key,
113
+ ref: this.ref,
114
+ });
115
+ if (Array.isArray(data) || data.type !== "file") return false;
116
+ await this.rest.repos.deleteFile({
117
+ owner: this.owner,
118
+ repo: this.repo,
119
+ path: key,
120
+ message: this.msg(key, null),
121
+ sha: data.sha,
122
+ branch: this.ref,
123
+ });
124
+ return true;
125
+ } catch (e: unknown) {
126
+ if (KeyvGithub.isHttpError(e) && e.status === 404) return false;
127
+ throw e;
128
+ }
129
+ }
130
+
131
+ async has(key: string): Promise<boolean> {
132
+ this.validateKey(key);
133
+ try {
134
+ const { data } = await this.rest.repos.getContent({
135
+ owner: this.owner,
136
+ repo: this.repo,
137
+ path: key,
138
+ ref: this.ref,
139
+ });
140
+ return !Array.isArray(data) && data.type === "file";
141
+ } catch (e: unknown) {
142
+ if (KeyvGithub.isHttpError(e) && e.status === 404) return false;
143
+ throw e;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Commit multiple file changes in one roundtrip: 5 API calls for any N.
149
+ * set entries: written inline into the tree (no separate blob creation).
150
+ * delete paths: removed by setting sha: null in the tree.
151
+ */
152
+ private async _batchCommit(params: {
153
+ set?: [string, string][];
154
+ delete?: string[];
155
+ message: string;
156
+ }): Promise<void> {
157
+ const { set = [], delete: del = [], message } = params;
158
+
159
+ const { data: refData } = await this.rest.git.getRef({
160
+ owner: this.owner,
161
+ repo: this.repo,
162
+ ref: `heads/${this.ref}`,
163
+ });
164
+ const headSha = refData.object.sha;
165
+
166
+ const { data: commitData } = await this.rest.git.getCommit({
167
+ owner: this.owner,
168
+ repo: this.repo,
169
+ commit_sha: headSha,
170
+ });
171
+
172
+ const treeEntries = [
173
+ ...set.map(([path, content]) => ({
174
+ path,
175
+ mode: "100644" as const,
176
+ type: "blob" as const,
177
+ content,
178
+ })),
179
+ ...del.map((path) => ({
180
+ path,
181
+ mode: "100644" as const,
182
+ type: "blob" as const,
183
+ sha: null,
184
+ })),
185
+ ];
186
+
187
+ const { data: newTree } = await this.rest.git.createTree({
188
+ owner: this.owner,
189
+ repo: this.repo,
190
+ base_tree: commitData.tree.sha,
191
+ tree: treeEntries,
192
+ });
193
+
194
+ const { data: newCommit } = await this.rest.git.createCommit({
195
+ owner: this.owner,
196
+ repo: this.repo,
197
+ message,
198
+ tree: newTree.sha,
199
+ parents: [headSha],
200
+ });
201
+
202
+ await this.rest.git.updateRef({
203
+ owner: this.owner,
204
+ repo: this.repo,
205
+ ref: `heads/${this.ref}`,
206
+ sha: newCommit.sha,
207
+ });
208
+ }
209
+
210
+ /** Keyv batch-set: writes multiple keys in a single commit (5 API calls total). */
211
+ async setMany(values: Array<{ key: string; value: any; ttl?: number }>): Promise<void> {
212
+ if (values.length === 0) return;
213
+ const entries: [string, string][] = values.map(({ key, value }) => [key, String(value)]);
214
+ for (const [key] of entries) this.validateKey(key);
215
+ const message =
216
+ entries.length === 1
217
+ ? this.msg(entries[0]![0], entries[0]![1])
218
+ : `batch update ${entries.length} files`;
219
+ await this._batchCommit({ set: entries, message });
220
+ }
221
+
222
+ /**
223
+ * Keyv batch-delete: deletes multiple keys in a single commit (7 API calls total).
224
+ * Returns true if any keys were deleted.
225
+ */
226
+ async deleteMany(keys: string[]): Promise<boolean> {
227
+ if (keys.length === 0) return false;
228
+ for (const key of keys) this.validateKey(key);
229
+
230
+ const { data: refData } = await this.rest.git.getRef({
231
+ owner: this.owner,
232
+ repo: this.repo,
233
+ ref: `heads/${this.ref}`,
234
+ });
235
+ const { data: treeData } = await this.rest.git.getTree({
236
+ owner: this.owner,
237
+ repo: this.repo,
238
+ tree_sha: refData.object.sha,
239
+ recursive: "1",
240
+ });
241
+ const existingPaths = new Set(
242
+ treeData.tree.filter((i: { type?: string; path?: string }) => i.type === "blob" && i.path).map((i: { path?: string }) => i.path!)
243
+ );
244
+
245
+ const toDelete = keys.filter((k) => existingPaths.has(k));
246
+ if (toDelete.length === 0) return false;
247
+
248
+ const message =
249
+ toDelete.length === 1
250
+ ? this.msg(toDelete[0]!, null)
251
+ : `batch delete ${toDelete.length} files`;
252
+ await this._batchCommit({ delete: toDelete, message });
253
+ return true;
254
+ }
255
+
256
+ async clear(): Promise<void> {
257
+ if (!this.enableClear)
258
+ throw new Error("clear() is disabled. Set enableClear: true in options to allow it.");
259
+
260
+ const { data: refData } = await this.rest.git.getRef({
261
+ owner: this.owner,
262
+ repo: this.repo,
263
+ ref: `heads/${this.ref}`,
264
+ });
265
+ const { data: treeData } = await this.rest.git.getTree({
266
+ owner: this.owner,
267
+ repo: this.repo,
268
+ tree_sha: refData.object.sha,
269
+ recursive: "1",
270
+ });
271
+
272
+ const allPaths = treeData.tree
273
+ .filter((i) => i.type === "blob" && i.path)
274
+ .map((i) => i.path!);
275
+
276
+ if (allPaths.length > 0) {
277
+ await this._batchCommit({
278
+ delete: allPaths,
279
+ message: `clear: remove ${allPaths.length} files`,
280
+ });
281
+ }
282
+ }
283
+
284
+ async *iterator<Value>(prefix?: string): AsyncGenerator<[string, Value | undefined]> {
285
+ const { data: refData } = await this.rest.git.getRef({
286
+ owner: this.owner,
287
+ repo: this.repo,
288
+ ref: `heads/${this.ref}`,
289
+ });
290
+ const { data: treeData } = await this.rest.git.getTree({
291
+ owner: this.owner,
292
+ repo: this.repo,
293
+ tree_sha: refData.object.sha,
294
+ recursive: "1",
295
+ });
296
+
297
+ const files = treeData.tree.filter(
298
+ (item) => item.type === "blob" && item.path && (!prefix || item.path.startsWith(prefix))
299
+ );
300
+
301
+ for (const file of files) {
302
+ if (file.path) {
303
+ const value = await this.get<Value>(file.path);
304
+ if (value !== undefined) yield [file.path, value as Value];
305
+ }
306
+ }
307
+ }
308
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }