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/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 validateKey;
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(/* @__PURE__ */ new RegExp("(?:.*github\\.com[/:])?([^/:]+)/([^/]+?)(?:\\.git)?(?:/tree/([^?#]+))?(?:[?#].*)?$"));
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
- 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}`);
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.validateKey(key);
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.validateKey(key);
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: key,
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: key,
84
- message: this.msg(key, value),
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.validateKey(key);
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: key,
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: key,
104
- message: this.msg(key, null),
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.validateKey(key);
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 entries = values.map(({ key, value }) => [key, String(value)]);
182
- for (const [key] of entries) this.validateKey(key);
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.validateKey(key);
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.filter((k) => existingPaths.has(k));
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 files = treeData.tree.filter((item) => item.type === "blob" && item.path && (!prefix || item.path.startsWith(prefix)));
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 value = await this.get(file.path);
251
- if (value !== void 0) yield [file.path, value];
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
  };
@@ -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", { client });
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
- "/absolute/path",
24
- "trailing/slash/",
25
- "double//slash",
26
- "../escape",
27
- "a/./b",
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
- try {
33
- await kv.get(bad);
34
- } catch (e: any) {
35
- console.log(` ✗ ${JSON.stringify(bad)} → ${e.message}`);
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
- "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
- }
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
  }