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/dist/index.mjs ADDED
@@ -0,0 +1,257 @@
1
+ import { EventEmitter } from "events";
2
+ import { Octokit } from "octokit";
3
+
4
+ //#region src/index.ts
5
+ /**
6
+ * Keyv storage adapter backed by a GitHub repository.
7
+ *
8
+ * Each key is a file path in the repo; the file content is the value.
9
+ * Example: new KeyvGithub("https://github.com/owner/repo/tree/main", { client })
10
+ */
11
+ var KeyvGithub = class KeyvGithub extends EventEmitter {
12
+ opts;
13
+ namespace;
14
+ owner;
15
+ repo;
16
+ ref;
17
+ /** Alias for {@link ref}. */
18
+ get branch() {
19
+ return this.ref;
20
+ }
21
+ rest;
22
+ msg;
23
+ enableClear;
24
+ constructor(url, options = {}) {
25
+ super();
26
+ const match = url.match(/* @__PURE__ */ new RegExp("(?:.*github\\.com[/:])?([^/:]+)/([^/]+?)(?:\\.git)?(?:/tree/([^?#]+))?(?:[?#].*)?$"));
27
+ if (!match) throw new Error(`Invalid GitHub repo URL: ${url}`);
28
+ this.owner = match[1];
29
+ this.repo = match[2];
30
+ this.ref = options.branch ?? match[3] ?? "main";
31
+ this.opts = {
32
+ url,
33
+ ...options
34
+ };
35
+ this.rest = options.client instanceof Octokit ? options.client.rest : options.client ?? new Octokit().rest;
36
+ this.msg = options.msg ?? ((key, value) => value === null ? `delete ${key}` : `update ${key}`);
37
+ this.enableClear = options.enableClear ?? false;
38
+ }
39
+ static isHttpError(e) {
40
+ return typeof e === "object" && e !== null && "status" in e && typeof e.status === "number";
41
+ }
42
+ validateKey(key) {
43
+ if (!key) throw new Error("Key must not be empty");
44
+ if (key.startsWith("/")) throw new Error(`Key must not start with '/': ${key}`);
45
+ if (key.endsWith("/")) throw new Error(`Key must not end with '/': ${key}`);
46
+ if (key.includes("//")) throw new Error(`Key must not contain '//': ${key}`);
47
+ if (key.includes("\0")) throw new Error(`Key must not contain null bytes: ${key}`);
48
+ if (key.split("/").some((seg) => seg === ".." || seg === ".")) throw new Error(`Key must not contain '.' or '..' segments: ${key}`);
49
+ }
50
+ async get(key) {
51
+ this.validateKey(key);
52
+ try {
53
+ const { data } = await this.rest.repos.getContent({
54
+ owner: this.owner,
55
+ repo: this.repo,
56
+ path: key,
57
+ ref: this.ref
58
+ });
59
+ if (Array.isArray(data) || data.type !== "file") return void 0;
60
+ return Buffer.from(data.content, "base64").toString("utf-8");
61
+ } catch (e) {
62
+ if (KeyvGithub.isHttpError(e) && e.status === 404) return void 0;
63
+ throw e;
64
+ }
65
+ }
66
+ async set(key, value, _ttl) {
67
+ this.validateKey(key);
68
+ let sha;
69
+ try {
70
+ const { data } = await this.rest.repos.getContent({
71
+ owner: this.owner,
72
+ repo: this.repo,
73
+ path: key,
74
+ ref: this.ref
75
+ });
76
+ if (!Array.isArray(data) && data.type === "file") sha = data.sha;
77
+ } catch (e) {
78
+ if (!KeyvGithub.isHttpError(e) || e.status !== 404) throw e;
79
+ }
80
+ await this.rest.repos.createOrUpdateFileContents({
81
+ owner: this.owner,
82
+ repo: this.repo,
83
+ path: key,
84
+ message: this.msg(key, value),
85
+ content: Buffer.from(String(value)).toString("base64"),
86
+ sha,
87
+ branch: this.ref
88
+ });
89
+ }
90
+ async delete(key) {
91
+ this.validateKey(key);
92
+ try {
93
+ const { data } = await this.rest.repos.getContent({
94
+ owner: this.owner,
95
+ repo: this.repo,
96
+ path: key,
97
+ ref: this.ref
98
+ });
99
+ if (Array.isArray(data) || data.type !== "file") return false;
100
+ await this.rest.repos.deleteFile({
101
+ owner: this.owner,
102
+ repo: this.repo,
103
+ path: key,
104
+ message: this.msg(key, null),
105
+ sha: data.sha,
106
+ branch: this.ref
107
+ });
108
+ return true;
109
+ } catch (e) {
110
+ if (KeyvGithub.isHttpError(e) && e.status === 404) return false;
111
+ throw e;
112
+ }
113
+ }
114
+ async has(key) {
115
+ this.validateKey(key);
116
+ try {
117
+ const { data } = await this.rest.repos.getContent({
118
+ owner: this.owner,
119
+ repo: this.repo,
120
+ path: key,
121
+ ref: this.ref
122
+ });
123
+ return !Array.isArray(data) && data.type === "file";
124
+ } catch (e) {
125
+ if (KeyvGithub.isHttpError(e) && e.status === 404) return false;
126
+ throw e;
127
+ }
128
+ }
129
+ /**
130
+ * Commit multiple file changes in one roundtrip: 5 API calls for any N.
131
+ * set entries: written inline into the tree (no separate blob creation).
132
+ * delete paths: removed by setting sha: null in the tree.
133
+ */
134
+ async _batchCommit(params) {
135
+ const { set = [], delete: del = [], message } = params;
136
+ const { data: refData } = await this.rest.git.getRef({
137
+ owner: this.owner,
138
+ repo: this.repo,
139
+ ref: `heads/${this.ref}`
140
+ });
141
+ const headSha = refData.object.sha;
142
+ const { data: commitData } = await this.rest.git.getCommit({
143
+ owner: this.owner,
144
+ repo: this.repo,
145
+ commit_sha: headSha
146
+ });
147
+ const treeEntries = [...set.map(([path, content]) => ({
148
+ path,
149
+ mode: "100644",
150
+ type: "blob",
151
+ content
152
+ })), ...del.map((path) => ({
153
+ path,
154
+ mode: "100644",
155
+ type: "blob",
156
+ sha: null
157
+ }))];
158
+ const { data: newTree } = await this.rest.git.createTree({
159
+ owner: this.owner,
160
+ repo: this.repo,
161
+ base_tree: commitData.tree.sha,
162
+ tree: treeEntries
163
+ });
164
+ const { data: newCommit } = await this.rest.git.createCommit({
165
+ owner: this.owner,
166
+ repo: this.repo,
167
+ message,
168
+ tree: newTree.sha,
169
+ parents: [headSha]
170
+ });
171
+ await this.rest.git.updateRef({
172
+ owner: this.owner,
173
+ repo: this.repo,
174
+ ref: `heads/${this.ref}`,
175
+ sha: newCommit.sha
176
+ });
177
+ }
178
+ /** Keyv batch-set: writes multiple keys in a single commit (5 API calls total). */
179
+ async setMany(values) {
180
+ if (values.length === 0) return;
181
+ const entries = values.map(({ key, value }) => [key, String(value)]);
182
+ for (const [key] of entries) this.validateKey(key);
183
+ const message = entries.length === 1 ? this.msg(entries[0][0], entries[0][1]) : `batch update ${entries.length} files`;
184
+ await this._batchCommit({
185
+ set: entries,
186
+ message
187
+ });
188
+ }
189
+ /**
190
+ * Keyv batch-delete: deletes multiple keys in a single commit (7 API calls total).
191
+ * Returns true if any keys were deleted.
192
+ */
193
+ async deleteMany(keys) {
194
+ if (keys.length === 0) return false;
195
+ for (const key of keys) this.validateKey(key);
196
+ const { data: refData } = await this.rest.git.getRef({
197
+ owner: this.owner,
198
+ repo: this.repo,
199
+ ref: `heads/${this.ref}`
200
+ });
201
+ const { data: treeData } = await this.rest.git.getTree({
202
+ owner: this.owner,
203
+ repo: this.repo,
204
+ tree_sha: refData.object.sha,
205
+ recursive: "1"
206
+ });
207
+ const existingPaths = new Set(treeData.tree.filter((i) => i.type === "blob" && i.path).map((i) => i.path));
208
+ const toDelete = keys.filter((k) => existingPaths.has(k));
209
+ if (toDelete.length === 0) return false;
210
+ const message = toDelete.length === 1 ? this.msg(toDelete[0], null) : `batch delete ${toDelete.length} files`;
211
+ await this._batchCommit({
212
+ delete: toDelete,
213
+ message
214
+ });
215
+ return true;
216
+ }
217
+ async clear() {
218
+ if (!this.enableClear) throw new Error("clear() is disabled. Set enableClear: true in options to allow it.");
219
+ const { data: refData } = await this.rest.git.getRef({
220
+ owner: this.owner,
221
+ repo: this.repo,
222
+ ref: `heads/${this.ref}`
223
+ });
224
+ const { data: treeData } = await this.rest.git.getTree({
225
+ owner: this.owner,
226
+ repo: this.repo,
227
+ tree_sha: refData.object.sha,
228
+ recursive: "1"
229
+ });
230
+ const allPaths = treeData.tree.filter((i) => i.type === "blob" && i.path).map((i) => i.path);
231
+ if (allPaths.length > 0) await this._batchCommit({
232
+ delete: allPaths,
233
+ message: `clear: remove ${allPaths.length} files`
234
+ });
235
+ }
236
+ async *iterator(prefix) {
237
+ const { data: refData } = await this.rest.git.getRef({
238
+ owner: this.owner,
239
+ repo: this.repo,
240
+ ref: `heads/${this.ref}`
241
+ });
242
+ const { data: treeData } = await this.rest.git.getTree({
243
+ owner: this.owner,
244
+ repo: this.repo,
245
+ tree_sha: refData.object.sha,
246
+ recursive: "1"
247
+ });
248
+ const files = treeData.tree.filter((item) => item.type === "blob" && item.path && (!prefix || item.path.startsWith(prefix)));
249
+ for (const file of files) if (file.path) {
250
+ const value = await this.get(file.path);
251
+ if (value !== void 0) yield [file.path, value];
252
+ }
253
+ }
254
+ };
255
+
256
+ //#endregion
257
+ export { KeyvGithub as default };
@@ -0,0 +1,37 @@
1
+ import { Octokit } from "octokit";
2
+ import KeyvGithub from "../src/index.ts";
3
+
4
+ // Authenticate via GITHUB_TOKEN env var (required for writes)
5
+ const client = new Octokit({ auth: process.env.GITHUB_TOKEN });
6
+
7
+ const kv = new KeyvGithub("https://github.com/snomiao/keyv-github/tree/main", { client });
8
+
9
+ const key = "data/hello-today.txt";
10
+ const value = new Date().toISOString();
11
+
12
+ console.log(`set ${key} = ${value}`);
13
+ await kv.set(key, value);
14
+
15
+ const read = await kv.get(key);
16
+ console.log(`get ${key} = ${read}`);
17
+
18
+ console.log(`has ${key} = ${await kv.has(key)}`);
19
+
20
+ // Demonstrate path validation — these throw immediately without hitting the API
21
+ const invalidPaths = [
22
+ "",
23
+ "/absolute/path",
24
+ "trailing/slash/",
25
+ "double//slash",
26
+ "../escape",
27
+ "a/./b",
28
+ ];
29
+
30
+ console.log("\nPath validation:");
31
+ for (const bad of invalidPaths) {
32
+ try {
33
+ await kv.get(bad);
34
+ } catch (e: any) {
35
+ console.log(` ✗ ${JSON.stringify(bad)} → ${e.message}`);
36
+ }
37
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "keyv-github",
3
+ "version": "1.0.0",
4
+ "module": "src/index.ts",
5
+ "main": "dist/index.mjs",
6
+ "types": "dist/index.d.mts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.mjs",
10
+ "types": "./dist/index.d.mts"
11
+ }
12
+ },
13
+ "type": "module",
14
+ "dependencies": {
15
+ "keyv": "^5.6.0",
16
+ "octokit": "^5.0.5"
17
+ },
18
+ "devDependencies": {
19
+ "@types/bun": "latest",
20
+ "tsdown": "^0.20.3",
21
+ "typescript": "^5.9.3"
22
+ },
23
+ "scripts": {
24
+ "build": "tsdown",
25
+ "test": "bun test"
26
+ }
27
+ }