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/CLAUDE.md +106 -0
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/bun.lock +235 -0
- package/data/hello-today.txt +1 -0
- package/dist/index.d.mts +60 -0
- package/dist/index.mjs +257 -0
- package/examples/hello-today.ts +37 -0
- package/package.json +27 -0
- package/src/index.test.ts +463 -0
- package/src/index.ts +308 -0
- package/tsconfig.json +29 -0
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
|
+
}
|