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/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 validateKey;
47
+ private validatePath;
35
48
  get<Value>(key: string): Promise<StoredData<Value> | undefined>;
36
- set(key: string, value: any, _ttl?: number): Promise<void>;
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(/* @__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;
@@ -63,14 +80,17 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
63
80
  throw e;
64
81
  }
65
82
  }
66
- async set(key, value, _ttl) {
67
- this.validateKey(key);
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: key,
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: key,
84
- message: this.msg(key, value),
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.validateKey(key);
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: key,
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: key,
104
- message: this.msg(key, null),
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.validateKey(key);
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 entries = values.map(({ key, value }) => [key, String(value)]);
182
- for (const [key] of entries) this.validateKey(key);
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.validateKey(key);
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.filter((k) => existingPaths.has(k));
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 files = treeData.tree.filter((item) => item.type === "blob" && item.path && (!prefix || item.path.startsWith(prefix)));
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 value = await this.get(file.path);
251
- if (value !== void 0) yield [file.path, value];
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
  };
@@ -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.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
  }