keyv-github 1.0.0 → 1.2.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 +45 -9
- package/bun.lock +885 -0
- package/dist/index.d.mts +15 -3
- package/dist/index.mjs +56 -29
- package/examples/hello-today.ts +14 -12
- package/package.json +30 -25
- package/src/index.test.ts +578 -411
- package/src/index.ts +390 -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,11 +33,20 @@ 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
|
-
set(key: string, value: any,
|
|
49
|
+
set(key: string, value: any, ttl?: number): Promise<void>;
|
|
37
50
|
delete(key: string): Promise<boolean>;
|
|
38
51
|
has(key: string): Promise<boolean>;
|
|
39
52
|
/**
|
|
@@ -46,7 +59,6 @@ declare class KeyvGithub extends EventEmitter implements KeyvStoreAdapter {
|
|
|
46
59
|
setMany(values: Array<{
|
|
47
60
|
key: string;
|
|
48
61
|
value: any;
|
|
49
|
-
ttl?: number;
|
|
50
62
|
}>): Promise<void>;
|
|
51
63
|
/**
|
|
52
64
|
* Keyv batch-delete: deletes multiple keys in a single commit (7 API calls total).
|
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;
|
|
@@ -63,14 +80,17 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
63
80
|
throw e;
|
|
64
81
|
}
|
|
65
82
|
}
|
|
66
|
-
async set(key, value,
|
|
67
|
-
|
|
83
|
+
async set(key, value, ttl) {
|
|
84
|
+
if (ttl !== void 0) throw new Error("TTL is not supported natively by keyv-github. Use new Keyv(store) which handles TTL via value expiration metadata.");
|
|
85
|
+
if (typeof value !== "string") throw new Error("keyv-github only supports string values natively. Use new Keyv(store) which serializes values automatically.");
|
|
86
|
+
this.validatePath(this.toPath(key));
|
|
87
|
+
const path = this.toPath(key);
|
|
68
88
|
let sha;
|
|
69
89
|
try {
|
|
70
90
|
const { data } = await this.rest.repos.getContent({
|
|
71
91
|
owner: this.owner,
|
|
72
92
|
repo: this.repo,
|
|
73
|
-
path
|
|
93
|
+
path,
|
|
74
94
|
ref: this.ref
|
|
75
95
|
});
|
|
76
96
|
if (!Array.isArray(data) && data.type === "file") sha = data.sha;
|
|
@@ -80,28 +100,29 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
80
100
|
await this.rest.repos.createOrUpdateFileContents({
|
|
81
101
|
owner: this.owner,
|
|
82
102
|
repo: this.repo,
|
|
83
|
-
path
|
|
84
|
-
message: this.msg(
|
|
103
|
+
path,
|
|
104
|
+
message: this.msg(path, value),
|
|
85
105
|
content: Buffer.from(String(value)).toString("base64"),
|
|
86
106
|
sha,
|
|
87
107
|
branch: this.ref
|
|
88
108
|
});
|
|
89
109
|
}
|
|
90
110
|
async delete(key) {
|
|
91
|
-
this.
|
|
111
|
+
this.validatePath(this.toPath(key));
|
|
112
|
+
const path = this.toPath(key);
|
|
92
113
|
try {
|
|
93
114
|
const { data } = await this.rest.repos.getContent({
|
|
94
115
|
owner: this.owner,
|
|
95
116
|
repo: this.repo,
|
|
96
|
-
path
|
|
117
|
+
path,
|
|
97
118
|
ref: this.ref
|
|
98
119
|
});
|
|
99
120
|
if (Array.isArray(data) || data.type !== "file") return false;
|
|
100
121
|
await this.rest.repos.deleteFile({
|
|
101
122
|
owner: this.owner,
|
|
102
123
|
repo: this.repo,
|
|
103
|
-
path
|
|
104
|
-
message: this.msg(
|
|
124
|
+
path,
|
|
125
|
+
message: this.msg(path, null),
|
|
105
126
|
sha: data.sha,
|
|
106
127
|
branch: this.ref
|
|
107
128
|
});
|
|
@@ -112,12 +133,12 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
112
133
|
}
|
|
113
134
|
}
|
|
114
135
|
async has(key) {
|
|
115
|
-
this.
|
|
136
|
+
this.validatePath(this.toPath(key));
|
|
116
137
|
try {
|
|
117
138
|
const { data } = await this.rest.repos.getContent({
|
|
118
139
|
owner: this.owner,
|
|
119
140
|
repo: this.repo,
|
|
120
|
-
path: key,
|
|
141
|
+
path: this.toPath(key),
|
|
121
142
|
ref: this.ref
|
|
122
143
|
});
|
|
123
144
|
return !Array.isArray(data) && data.type === "file";
|
|
@@ -178,8 +199,11 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
178
199
|
/** Keyv batch-set: writes multiple keys in a single commit (5 API calls total). */
|
|
179
200
|
async setMany(values) {
|
|
180
201
|
if (values.length === 0) return;
|
|
181
|
-
const
|
|
182
|
-
|
|
202
|
+
for (const { key, value } of values) {
|
|
203
|
+
if (typeof value !== "string") throw new Error("keyv-github only supports string values natively. Use new Keyv(store) which serializes values automatically.");
|
|
204
|
+
this.validatePath(this.toPath(key));
|
|
205
|
+
}
|
|
206
|
+
const entries = values.map(({ key, value }) => [this.toPath(key), String(value)]);
|
|
183
207
|
const message = entries.length === 1 ? this.msg(entries[0][0], entries[0][1]) : `batch update ${entries.length} files`;
|
|
184
208
|
await this._batchCommit({
|
|
185
209
|
set: entries,
|
|
@@ -192,7 +216,7 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
192
216
|
*/
|
|
193
217
|
async deleteMany(keys) {
|
|
194
218
|
if (keys.length === 0) return false;
|
|
195
|
-
for (const key of keys) this.
|
|
219
|
+
for (const key of keys) this.validatePath(this.toPath(key));
|
|
196
220
|
const { data: refData } = await this.rest.git.getRef({
|
|
197
221
|
owner: this.owner,
|
|
198
222
|
repo: this.repo,
|
|
@@ -205,7 +229,7 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
205
229
|
recursive: "1"
|
|
206
230
|
});
|
|
207
231
|
const existingPaths = new Set(treeData.tree.filter((i) => i.type === "blob" && i.path).map((i) => i.path));
|
|
208
|
-
const toDelete = keys.
|
|
232
|
+
const toDelete = keys.map((k) => this.toPath(k)).filter((p) => existingPaths.has(p));
|
|
209
233
|
if (toDelete.length === 0) return false;
|
|
210
234
|
const message = toDelete.length === 1 ? this.msg(toDelete[0], null) : `batch delete ${toDelete.length} files`;
|
|
211
235
|
await this._batchCommit({
|
|
@@ -227,7 +251,7 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
227
251
|
tree_sha: refData.object.sha,
|
|
228
252
|
recursive: "1"
|
|
229
253
|
});
|
|
230
|
-
const allPaths = treeData.tree.filter((i) => i.type === "blob" && i.path).map((i) => i.path);
|
|
254
|
+
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
255
|
if (allPaths.length > 0) await this._batchCommit({
|
|
232
256
|
delete: allPaths,
|
|
233
257
|
message: `clear: remove ${allPaths.length} files`
|
|
@@ -245,10 +269,13 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
|
|
|
245
269
|
tree_sha: refData.object.sha,
|
|
246
270
|
recursive: "1"
|
|
247
271
|
});
|
|
248
|
-
const
|
|
272
|
+
const pathPrefix = this.prefix + (prefix ?? "");
|
|
273
|
+
const files = treeData.tree.filter((item) => item.type === "blob" && item.path && item.path.startsWith(pathPrefix) && item.path.endsWith(this.suffix));
|
|
249
274
|
for (const file of files) if (file.path) {
|
|
250
|
-
const
|
|
251
|
-
if (
|
|
275
|
+
const key = this.fromPath(file.path);
|
|
276
|
+
if (key === null || key === "") continue;
|
|
277
|
+
const value = await this.get(key);
|
|
278
|
+
if (value !== void 0) yield [key, value];
|
|
252
279
|
}
|
|
253
280
|
}
|
|
254
281
|
};
|
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.2.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
|
}
|