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/dist/index.d.mts
CHANGED
|
@@ -11,6 +11,10 @@ interface KeyvGithubOptions {
|
|
|
11
11
|
msg?: (key: string, value: string | null) => string;
|
|
12
12
|
/** clear() deletes every file in the repo and is disabled by default. Set to true to allow it. */
|
|
13
13
|
enableClear?: boolean;
|
|
14
|
+
/** Path prefix prepended to every key (e.g. 'data/'). Defaults to ''. */
|
|
15
|
+
prefix?: string;
|
|
16
|
+
/** Path suffix appended to every key (e.g. '.json'). Defaults to ''. */
|
|
17
|
+
suffix?: string;
|
|
14
18
|
}
|
|
15
19
|
/**
|
|
16
20
|
* Keyv storage adapter backed by a GitHub repository.
|
|
@@ -29,9 +33,18 @@ declare class KeyvGithub extends EventEmitter implements KeyvStoreAdapter {
|
|
|
29
33
|
rest: Octokit["rest"];
|
|
30
34
|
private msg;
|
|
31
35
|
readonly enableClear: boolean;
|
|
36
|
+
readonly prefix: string;
|
|
37
|
+
readonly suffix: string;
|
|
32
38
|
constructor(url: string, options?: Omit<KeyvGithubOptions, "url">);
|
|
39
|
+
/** Converts a user key to the GitHub file path. */
|
|
40
|
+
private toPath;
|
|
41
|
+
/**
|
|
42
|
+
* Converts a GitHub file path back to a user key.
|
|
43
|
+
* Returns null if the path does not match the configured prefix/suffix.
|
|
44
|
+
*/
|
|
45
|
+
private fromPath;
|
|
33
46
|
private static isHttpError;
|
|
34
|
-
private
|
|
47
|
+
private validatePath;
|
|
35
48
|
get<Value>(key: string): Promise<StoredData<Value> | undefined>;
|
|
36
49
|
set(key: string, value: any, _ttl?: number): Promise<void>;
|
|
37
50
|
delete(key: string): Promise<boolean>;
|
package/dist/index.mjs
CHANGED
|
@@ -21,9 +21,11 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
21
21
|
rest;
|
|
22
22
|
msg;
|
|
23
23
|
enableClear;
|
|
24
|
+
prefix;
|
|
25
|
+
suffix;
|
|
24
26
|
constructor(url, options = {}) {
|
|
25
27
|
super();
|
|
26
|
-
const match = url.match(
|
|
28
|
+
const match = url.match(/(?:.*github\.com[/:])?([^/:]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^?#]+))?(?:[?#].*)?$/);
|
|
27
29
|
if (!match) throw new Error(`Invalid GitHub repo URL: ${url}`);
|
|
28
30
|
this.owner = match[1];
|
|
29
31
|
this.repo = match[2];
|
|
@@ -35,25 +37,40 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
35
37
|
this.rest = options.client instanceof Octokit ? options.client.rest : options.client ?? new Octokit().rest;
|
|
36
38
|
this.msg = options.msg ?? ((key, value) => value === null ? `delete ${key}` : `update ${key}`);
|
|
37
39
|
this.enableClear = options.enableClear ?? false;
|
|
40
|
+
this.prefix = options.prefix ?? "";
|
|
41
|
+
this.suffix = options.suffix ?? "";
|
|
42
|
+
}
|
|
43
|
+
/** Converts a user key to the GitHub file path. */
|
|
44
|
+
toPath(key) {
|
|
45
|
+
return this.prefix + key + this.suffix;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Converts a GitHub file path back to a user key.
|
|
49
|
+
* Returns null if the path does not match the configured prefix/suffix.
|
|
50
|
+
*/
|
|
51
|
+
fromPath(path) {
|
|
52
|
+
if (!path.startsWith(this.prefix) || !path.endsWith(this.suffix)) return null;
|
|
53
|
+
const end = this.suffix ? path.length - this.suffix.length : void 0;
|
|
54
|
+
return path.slice(this.prefix.length, end);
|
|
38
55
|
}
|
|
39
56
|
static isHttpError(e) {
|
|
40
57
|
return typeof e === "object" && e !== null && "status" in e && typeof e.status === "number";
|
|
41
58
|
}
|
|
42
|
-
|
|
43
|
-
if (!
|
|
44
|
-
if (
|
|
45
|
-
if (
|
|
46
|
-
if (
|
|
47
|
-
if (
|
|
48
|
-
if (
|
|
59
|
+
validatePath(path) {
|
|
60
|
+
if (!path) throw new Error("Path must not be empty");
|
|
61
|
+
if (path.startsWith("/")) throw new Error(`Path must not start with '/': ${path}`);
|
|
62
|
+
if (path.endsWith("/")) throw new Error(`Path must not end with '/': ${path}`);
|
|
63
|
+
if (path.includes("//")) throw new Error(`Path must not contain '//': ${path}`);
|
|
64
|
+
if (path.includes("\0")) throw new Error(`Path must not contain null bytes: ${path}`);
|
|
65
|
+
if (path.split("/").some((seg) => seg === ".." || seg === ".")) throw new Error(`Path must not contain '.' or '..' segments: ${path}`);
|
|
49
66
|
}
|
|
50
67
|
async get(key) {
|
|
51
|
-
this.
|
|
68
|
+
this.validatePath(this.toPath(key));
|
|
52
69
|
try {
|
|
53
70
|
const { data } = await this.rest.repos.getContent({
|
|
54
71
|
owner: this.owner,
|
|
55
72
|
repo: this.repo,
|
|
56
|
-
path: key,
|
|
73
|
+
path: this.toPath(key),
|
|
57
74
|
ref: this.ref
|
|
58
75
|
});
|
|
59
76
|
if (Array.isArray(data) || data.type !== "file") return void 0;
|
|
@@ -64,13 +81,14 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
64
81
|
}
|
|
65
82
|
}
|
|
66
83
|
async set(key, value, _ttl) {
|
|
67
|
-
this.
|
|
84
|
+
this.validatePath(this.toPath(key));
|
|
85
|
+
const path = this.toPath(key);
|
|
68
86
|
let sha;
|
|
69
87
|
try {
|
|
70
88
|
const { data } = await this.rest.repos.getContent({
|
|
71
89
|
owner: this.owner,
|
|
72
90
|
repo: this.repo,
|
|
73
|
-
path
|
|
91
|
+
path,
|
|
74
92
|
ref: this.ref
|
|
75
93
|
});
|
|
76
94
|
if (!Array.isArray(data) && data.type === "file") sha = data.sha;
|
|
@@ -80,28 +98,29 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
80
98
|
await this.rest.repos.createOrUpdateFileContents({
|
|
81
99
|
owner: this.owner,
|
|
82
100
|
repo: this.repo,
|
|
83
|
-
path
|
|
84
|
-
message: this.msg(
|
|
101
|
+
path,
|
|
102
|
+
message: this.msg(path, value),
|
|
85
103
|
content: Buffer.from(String(value)).toString("base64"),
|
|
86
104
|
sha,
|
|
87
105
|
branch: this.ref
|
|
88
106
|
});
|
|
89
107
|
}
|
|
90
108
|
async delete(key) {
|
|
91
|
-
this.
|
|
109
|
+
this.validatePath(this.toPath(key));
|
|
110
|
+
const path = this.toPath(key);
|
|
92
111
|
try {
|
|
93
112
|
const { data } = await this.rest.repos.getContent({
|
|
94
113
|
owner: this.owner,
|
|
95
114
|
repo: this.repo,
|
|
96
|
-
path
|
|
115
|
+
path,
|
|
97
116
|
ref: this.ref
|
|
98
117
|
});
|
|
99
118
|
if (Array.isArray(data) || data.type !== "file") return false;
|
|
100
119
|
await this.rest.repos.deleteFile({
|
|
101
120
|
owner: this.owner,
|
|
102
121
|
repo: this.repo,
|
|
103
|
-
path
|
|
104
|
-
message: this.msg(
|
|
122
|
+
path,
|
|
123
|
+
message: this.msg(path, null),
|
|
105
124
|
sha: data.sha,
|
|
106
125
|
branch: this.ref
|
|
107
126
|
});
|
|
@@ -112,12 +131,12 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
112
131
|
}
|
|
113
132
|
}
|
|
114
133
|
async has(key) {
|
|
115
|
-
this.
|
|
134
|
+
this.validatePath(this.toPath(key));
|
|
116
135
|
try {
|
|
117
136
|
const { data } = await this.rest.repos.getContent({
|
|
118
137
|
owner: this.owner,
|
|
119
138
|
repo: this.repo,
|
|
120
|
-
path: key,
|
|
139
|
+
path: this.toPath(key),
|
|
121
140
|
ref: this.ref
|
|
122
141
|
});
|
|
123
142
|
return !Array.isArray(data) && data.type === "file";
|
|
@@ -178,8 +197,8 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
178
197
|
/** Keyv batch-set: writes multiple keys in a single commit (5 API calls total). */
|
|
179
198
|
async setMany(values) {
|
|
180
199
|
if (values.length === 0) return;
|
|
181
|
-
const
|
|
182
|
-
|
|
200
|
+
for (const { key } of values) this.validatePath(this.toPath(key));
|
|
201
|
+
const entries = values.map(({ key, value }) => [this.toPath(key), String(value)]);
|
|
183
202
|
const message = entries.length === 1 ? this.msg(entries[0][0], entries[0][1]) : `batch update ${entries.length} files`;
|
|
184
203
|
await this._batchCommit({
|
|
185
204
|
set: entries,
|
|
@@ -192,7 +211,7 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
192
211
|
*/
|
|
193
212
|
async deleteMany(keys) {
|
|
194
213
|
if (keys.length === 0) return false;
|
|
195
|
-
for (const key of keys) this.
|
|
214
|
+
for (const key of keys) this.validatePath(this.toPath(key));
|
|
196
215
|
const { data: refData } = await this.rest.git.getRef({
|
|
197
216
|
owner: this.owner,
|
|
198
217
|
repo: this.repo,
|
|
@@ -205,7 +224,7 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
205
224
|
recursive: "1"
|
|
206
225
|
});
|
|
207
226
|
const existingPaths = new Set(treeData.tree.filter((i) => i.type === "blob" && i.path).map((i) => i.path));
|
|
208
|
-
const toDelete = keys.
|
|
227
|
+
const toDelete = keys.map((k) => this.toPath(k)).filter((p) => existingPaths.has(p));
|
|
209
228
|
if (toDelete.length === 0) return false;
|
|
210
229
|
const message = toDelete.length === 1 ? this.msg(toDelete[0], null) : `batch delete ${toDelete.length} files`;
|
|
211
230
|
await this._batchCommit({
|
|
@@ -227,7 +246,7 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
227
246
|
tree_sha: refData.object.sha,
|
|
228
247
|
recursive: "1"
|
|
229
248
|
});
|
|
230
|
-
const allPaths = treeData.tree.filter((i) => i.type === "blob" && i.path).map((i) => i.path);
|
|
249
|
+
const allPaths = treeData.tree.filter((i) => i.type === "blob" && i.path && i.path.startsWith(this.prefix) && i.path.endsWith(this.suffix)).map((i) => i.path);
|
|
231
250
|
if (allPaths.length > 0) await this._batchCommit({
|
|
232
251
|
delete: allPaths,
|
|
233
252
|
message: `clear: remove ${allPaths.length} files`
|
|
@@ -245,10 +264,13 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
245
264
|
tree_sha: refData.object.sha,
|
|
246
265
|
recursive: "1"
|
|
247
266
|
});
|
|
248
|
-
const
|
|
267
|
+
const pathPrefix = this.prefix + (prefix ?? "");
|
|
268
|
+
const files = treeData.tree.filter((item) => item.type === "blob" && item.path && item.path.startsWith(pathPrefix) && item.path.endsWith(this.suffix));
|
|
249
269
|
for (const file of files) if (file.path) {
|
|
250
|
-
const
|
|
251
|
-
if (
|
|
270
|
+
const key = this.fromPath(file.path);
|
|
271
|
+
if (key === null || key === "") continue;
|
|
272
|
+
const value = await this.get(key);
|
|
273
|
+
if (value !== void 0) yield [key, value];
|
|
252
274
|
}
|
|
253
275
|
}
|
|
254
276
|
};
|
package/examples/hello-today.ts
CHANGED
|
@@ -4,7 +4,9 @@ import KeyvGithub from "../src/index.ts";
|
|
|
4
4
|
// Authenticate via GITHUB_TOKEN env var (required for writes)
|
|
5
5
|
const client = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
|
6
6
|
|
|
7
|
-
const kv = new KeyvGithub("https://github.com/snomiao/keyv-github/tree/main", {
|
|
7
|
+
const kv = new KeyvGithub("https://github.com/snomiao/keyv-github/tree/main", {
|
|
8
|
+
client,
|
|
9
|
+
});
|
|
8
10
|
|
|
9
11
|
const key = "data/hello-today.txt";
|
|
10
12
|
const value = new Date().toISOString();
|
|
@@ -19,19 +21,19 @@ console.log(`has ${key} = ${await kv.has(key)}`);
|
|
|
19
21
|
|
|
20
22
|
// Demonstrate path validation — these throw immediately without hitting the API
|
|
21
23
|
const invalidPaths = [
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
"",
|
|
25
|
+
"/absolute/path",
|
|
26
|
+
"trailing/slash/",
|
|
27
|
+
"double//slash",
|
|
28
|
+
"../escape",
|
|
29
|
+
"a/./b",
|
|
28
30
|
];
|
|
29
31
|
|
|
30
32
|
console.log("\nPath validation:");
|
|
31
33
|
for (const bad of invalidPaths) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
try {
|
|
35
|
+
await kv.get(bad);
|
|
36
|
+
} catch (e: any) {
|
|
37
|
+
console.log(` ✗ ${JSON.stringify(bad)} → ${e.message}`);
|
|
38
|
+
}
|
|
37
39
|
}
|
package/package.json
CHANGED
|
@@ -1,27 +1,32 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
2
|
+
"name": "keyv-github",
|
|
3
|
+
"version": "1.1.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
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/snomiao/keyv-github"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"keyv": "^5.6.0",
|
|
20
|
+
"octokit": "^5.0.5"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "latest",
|
|
24
|
+
"semantic-release": "^25.0.3",
|
|
25
|
+
"tsdown": "^0.20.3",
|
|
26
|
+
"typescript": "^5.9.3"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsdown",
|
|
30
|
+
"test": "bun test"
|
|
31
|
+
}
|
|
27
32
|
}
|