keyv-github 1.3.0 → 1.5.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.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Demo: Best Practice - 3-layer cache with keyv-nest
3
+ *
4
+ * This demo shows how to use memory + file + GitHub cache layers
5
+ * to minimize API calls while keeping data in sync.
6
+ *
7
+ * Run with GitHub:
8
+ * GITHUB_TOKEN=your_token GITHUB_REPO=owner/repo bun demo-best-practice.ts
9
+ *
10
+ * Run local-only (no GitHub):
11
+ * bun demo-best-practice.ts --local
12
+ */
13
+
14
+ import Keyv from "keyv";
15
+ import KeyvNest from "keyv-nest";
16
+ import { Octokit } from "octokit";
17
+ import KeyvGithub from "./src/index.ts";
18
+ // Use local version with prefix/suffix support (pending npm publish)
19
+ import { KeyvDirStore } from "./tmp/keyv-dir-store/index.ts";
20
+
21
+ const LOCAL_ONLY = process.argv.includes("--local");
22
+ const REPO = process.env.GITHUB_REPO || "snomiao/keyv-github-demo";
23
+ const TOKEN = process.env.GITHUB_TOKEN;
24
+
25
+ if (!LOCAL_ONLY && !TOKEN) {
26
+ console.log("No GITHUB_TOKEN set. Running in local-only mode.");
27
+ console.log("To sync with GitHub, run:");
28
+ console.log(
29
+ " GITHUB_TOKEN=xxx GITHUB_REPO=owner/repo bun demo-best-practice.ts\n",
30
+ );
31
+ }
32
+
33
+ // Use same prefix/suffix so local cache mirrors GitHub paths
34
+ const prefix = "data/";
35
+ const suffix = ".txt";
36
+
37
+ // Simple Map-based memory store (no namespace prefix)
38
+ const memoryStore = {
39
+ cache: new Map<string, any>(),
40
+ opts: { url: "", dialect: "map" },
41
+ get(key: string) {
42
+ return this.cache.get(key);
43
+ },
44
+ set(key: string, value: any) {
45
+ this.cache.set(key, value);
46
+ },
47
+ delete(key: string) {
48
+ return this.cache.delete(key);
49
+ },
50
+ clear() {
51
+ this.cache.clear();
52
+ },
53
+ };
54
+
55
+ // Build cache layers
56
+ const layers: any[] = [
57
+ memoryStore, // L1: Memory (fastest)
58
+ new KeyvDirStore("./cache", {
59
+ // L2: Local files (fast)
60
+ prefix,
61
+ suffix,
62
+ filename: (k) => k, // use key as-is, no hashing
63
+ }),
64
+ ];
65
+
66
+ // Add GitHub layer if token is available
67
+ if (TOKEN && !LOCAL_ONLY) {
68
+ const client = new Octokit({ auth: TOKEN });
69
+ layers.push(
70
+ new KeyvGithub(`${REPO}/tree/main`, { client, prefix, suffix }), // L3: GitHub
71
+ );
72
+ console.log(`Using 3-layer cache: Memory -> Files -> GitHub (${REPO})`);
73
+ } else {
74
+ console.log("Using 2-layer cache: Memory -> Files (local only)");
75
+ }
76
+
77
+ const store = KeyvNest(...layers);
78
+ // Add opts for Keyv compatibility
79
+ (store as any).opts = { url: "", dialect: "keyv-nest" };
80
+ // Use empty namespace to avoid keyv: prefix on keys
81
+ const kv = new Keyv({ store, namespace: "" });
82
+
83
+ // Demo: Write today's best thing
84
+ const today = new Date().toISOString().split("T")[0];
85
+ const content = `Today's best thing: ${today}\n\nWritten at: ${new Date().toISOString()}`;
86
+
87
+ console.log("\nSetting data/best-today.txt...");
88
+ await kv.set("best-today", content);
89
+
90
+ console.log("Reading back from cache layers...");
91
+ const result = await kv.get("best-today");
92
+ console.log("Content:", result);
93
+
94
+ console.log("\nDone! Check:");
95
+ console.log(` Local: ./cache/${prefix}best-today${suffix}`);
96
+ if (TOKEN && !LOCAL_ONLY) {
97
+ console.log(
98
+ ` GitHub: https://github.com/${REPO}/blob/main/${prefix}best-today${suffix}`,
99
+ );
100
+ }
package/dist/index.d.mts CHANGED
@@ -12,8 +12,20 @@ interface KeyvGithubOptions {
12
12
  url: string;
13
13
  branch?: string;
14
14
  client?: Octokit | Octokit["rest"];
15
- /** Customize the commit message. value is null for deletes. */
15
+ /**
16
+ * Customize the commit message for single-key operations. value is null for deletes.
17
+ * @warning Consider adding `[skip ci]` to your commit messages to prevent
18
+ * triggering CI workflows on each key-value update.
19
+ */
16
20
  msg?: (key: string, value: string | null) => string;
21
+ /**
22
+ * Customize the commit message for batch operations (setMany, deleteMany, clear).
23
+ * @param operation - 'set' | 'delete' | 'clear'
24
+ * @param paths - array of file paths being modified
25
+ * @warning Consider adding `[skip ci]` to your commit messages to prevent
26
+ * triggering CI workflows on each key-value update.
27
+ */
28
+ batchMsg?: (operation: "set" | "delete" | "clear", paths: string[]) => string;
17
29
  /** clear() deletes every file in the repo and is disabled by default. Set to true to allow it. */
18
30
  enableClear?: boolean;
19
31
  /** Path prefix prepended to every key (e.g. 'data/'). Defaults to ''. */
@@ -28,6 +40,11 @@ interface KeyvGithubOptions {
28
40
  *
29
41
  * Each key is a file path in the repo; the file content is the value.
30
42
  * Example: new KeyvGithub("https://github.com/owner/repo/tree/main", { client })
43
+ *
44
+ * @warning **Keys are validated but NOT sanitized.** You must ensure keys are valid
45
+ * GitHub file paths before calling any method. Invalid keys throw an error.
46
+ * Requirements: non-empty, no leading/trailing `/`, no `//`, no `.`/`..` segments, no null bytes.
47
+ * OS-specific invalid characters (e.g. `<>:"|?*\` on Windows) are NOT checked — sanitize them yourself.
31
48
  */
32
49
  declare class KeyvGithub extends EventEmitter implements KeyvStoreAdapter {
33
50
  opts: KeyvGithubOptions;
@@ -39,6 +56,7 @@ declare class KeyvGithub extends EventEmitter implements KeyvStoreAdapter {
39
56
  get branch(): string;
40
57
  rest: Octokit["rest"];
41
58
  private msg;
59
+ private batchMsg;
42
60
  readonly enableClear: boolean;
43
61
  readonly prefix: string;
44
62
  readonly suffix: string;
package/dist/index.mjs CHANGED
@@ -7,6 +7,11 @@ import { Octokit } from "octokit";
7
7
  *
8
8
  * Each key is a file path in the repo; the file content is the value.
9
9
  * Example: new KeyvGithub("https://github.com/owner/repo/tree/main", { client })
10
+ *
11
+ * @warning **Keys are validated but NOT sanitized.** You must ensure keys are valid
12
+ * GitHub file paths before calling any method. Invalid keys throw an error.
13
+ * Requirements: non-empty, no leading/trailing `/`, no `//`, no `.`/`..` segments, no null bytes.
14
+ * OS-specific invalid characters (e.g. `<>:"|?*\` on Windows) are NOT checked — sanitize them yourself.
10
15
  */
11
16
  var KeyvGithub = class KeyvGithub extends EventEmitter {
12
17
  opts;
@@ -20,6 +25,7 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
20
25
  }
21
26
  rest;
22
27
  msg;
28
+ batchMsg;
23
29
  enableClear;
24
30
  prefix;
25
31
  suffix;
@@ -37,7 +43,12 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
37
43
  ...options
38
44
  };
39
45
  this.rest = options.client instanceof Octokit ? options.client.rest : options.client ?? new Octokit().rest;
40
- this.msg = options.msg ?? ((key, value) => value === null ? `delete ${key}` : `update ${key}`);
46
+ this.msg = options.msg ?? ((key, value) => value === null ? `delete ${key} [skip ci]` : `update ${key} [skip ci]`);
47
+ this.batchMsg = options.batchMsg ?? ((op, paths) => {
48
+ const n = paths.length;
49
+ if (op === "clear") return `clear: remove ${n} files [skip ci]`;
50
+ return `batch ${op} ${n} files [skip ci]`;
51
+ });
41
52
  this.enableClear = options.enableClear ?? false;
42
53
  this.prefix = options.prefix ?? "";
43
54
  this.suffix = options.suffix ?? "";
@@ -223,7 +234,8 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
223
234
  this.validatePath(this.toPath(key));
224
235
  }
225
236
  const entries = values.map(({ key, value }) => [this.toPath(key), String(value)]);
226
- const message = entries.length === 1 ? this.msg(entries[0][0], entries[0][1]) : `batch update ${entries.length} files`;
237
+ const paths = entries.map(([p]) => p);
238
+ const message = entries.length === 1 ? this.msg(entries[0][0], entries[0][1]) : this.batchMsg("set", paths);
227
239
  await this._batchCommit({
228
240
  set: entries,
229
241
  message
@@ -250,7 +262,7 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
250
262
  const existingPaths = new Set(treeData.tree.filter((i) => i.type === "blob" && i.path).map((i) => i.path));
251
263
  const toDelete = keys.map((k) => this.toPath(k)).filter((p) => existingPaths.has(p));
252
264
  if (toDelete.length === 0) return false;
253
- const message = toDelete.length === 1 ? this.msg(toDelete[0], null) : `batch delete ${toDelete.length} files`;
265
+ const message = toDelete.length === 1 ? this.msg(toDelete[0], null) : this.batchMsg("delete", toDelete);
254
266
  await this._batchCommit({
255
267
  delete: toDelete,
256
268
  message
@@ -273,7 +285,7 @@ var KeyvGithub = class KeyvGithub extends EventEmitter {
273
285
  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);
274
286
  if (allPaths.length > 0) await this._batchCommit({
275
287
  delete: allPaths,
276
- message: `clear: remove ${allPaths.length} files`
288
+ message: this.batchMsg("clear", allPaths)
277
289
  });
278
290
  }
279
291
  async *iterator(prefix) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keyv-github",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "module": "src/index.ts",
5
5
  "main": "dist/index.mjs",
6
6
  "types": "dist/index.d.mts",
@@ -17,6 +17,8 @@
17
17
  "type": "module",
18
18
  "dependencies": {
19
19
  "keyv": "^5.6.0",
20
+ "keyv-dir-store": "^0.0.9",
21
+ "keyv-nest": "^0.0.7",
20
22
  "octokit": "^5.0.5"
21
23
  },
22
24
  "devDependencies": {
package/src/index.test.ts CHANGED
@@ -386,7 +386,7 @@ describe("setMany", () => {
386
386
  expect(mockFiles.get("b/2.txt")?.content).toBe("world");
387
387
  expect(mockFiles.get("c/3.txt")?.content).toBe("!");
388
388
  expect(messages).toHaveLength(1); // single commit
389
- expect(messages[0]).toBe("batch update 3 files");
389
+ expect(messages[0]).toBe("batch set 3 files [skip ci]");
390
390
  });
391
391
 
392
392
  test("uses msg hook for single-entry batch", async () => {
@@ -440,7 +440,7 @@ describe("deleteMany", () => {
440
440
  expect(mockFiles.has("b")).toBe(false);
441
441
  expect(mockFiles.has("c")).toBe(true);
442
442
  expect(messages).toHaveLength(1); // single commit
443
- expect(messages[0]).toBe("batch delete 2 files");
443
+ expect(messages[0]).toBe("batch delete 2 files [skip ci]");
444
444
  });
445
445
 
446
446
  test("returns false when no keys exist", async () => {
@@ -527,15 +527,15 @@ describe("msg hook", () => {
527
527
  expect(messages[messages.length - 1]).toBe("chore: rm to/remove");
528
528
  });
529
529
 
530
- test("default msg falls back to 'update <key>' / 'delete <key>'", async () => {
530
+ test("default msg falls back to 'update <key> [skip ci]' / 'delete <key> [skip ci]'", async () => {
531
531
  const { store, messages } = makeStore();
532
532
  await store.set("k", "v");
533
- expect(messages[messages.length - 1]).toBe("update k");
533
+ expect(messages[messages.length - 1]).toBe("update k [skip ci]");
534
534
 
535
535
  const files2 = new Map([["k2", { content: "v", sha: "s1" }]]);
536
536
  const { store: store2, messages: messages2 } = makeStore(files2);
537
537
  await store2.delete("k2");
538
- expect(messages2[messages2.length - 1]).toBe("delete k2");
538
+ expect(messages2[messages2.length - 1]).toBe("delete k2 [skip ci]");
539
539
  });
540
540
  });
541
541
 
package/src/index.ts CHANGED
@@ -12,8 +12,20 @@ export interface KeyvGithubOptions {
12
12
  url: string;
13
13
  branch?: string;
14
14
  client?: Octokit | Octokit["rest"];
15
- /** Customize the commit message. value is null for deletes. */
15
+ /**
16
+ * Customize the commit message for single-key operations. value is null for deletes.
17
+ * @warning Consider adding `[skip ci]` to your commit messages to prevent
18
+ * triggering CI workflows on each key-value update.
19
+ */
16
20
  msg?: (key: string, value: string | null) => string;
21
+ /**
22
+ * Customize the commit message for batch operations (setMany, deleteMany, clear).
23
+ * @param operation - 'set' | 'delete' | 'clear'
24
+ * @param paths - array of file paths being modified
25
+ * @warning Consider adding `[skip ci]` to your commit messages to prevent
26
+ * triggering CI workflows on each key-value update.
27
+ */
28
+ batchMsg?: (operation: "set" | "delete" | "clear", paths: string[]) => string;
17
29
  /** clear() deletes every file in the repo and is disabled by default. Set to true to allow it. */
18
30
  enableClear?: boolean;
19
31
  /** Path prefix prepended to every key (e.g. 'data/'). Defaults to ''. */
@@ -29,6 +41,11 @@ export interface KeyvGithubOptions {
29
41
  *
30
42
  * Each key is a file path in the repo; the file content is the value.
31
43
  * Example: new KeyvGithub("https://github.com/owner/repo/tree/main", { client })
44
+ *
45
+ * @warning **Keys are validated but NOT sanitized.** You must ensure keys are valid
46
+ * GitHub file paths before calling any method. Invalid keys throw an error.
47
+ * Requirements: non-empty, no leading/trailing `/`, no `//`, no `.`/`..` segments, no null bytes.
48
+ * OS-specific invalid characters (e.g. `<>:"|?*\` on Windows) are NOT checked — sanitize them yourself.
32
49
  */
33
50
  export default class KeyvGithub
34
51
  extends EventEmitter
@@ -46,6 +63,10 @@ export default class KeyvGithub
46
63
  }
47
64
  rest: Octokit["rest"];
48
65
  private msg: (key: string, value: string | null) => string;
66
+ private batchMsg: (
67
+ operation: "set" | "delete" | "clear",
68
+ paths: string[],
69
+ ) => string;
49
70
  readonly enableClear: boolean;
50
71
  readonly prefix: string;
51
72
  readonly suffix: string;
@@ -70,7 +91,15 @@ export default class KeyvGithub
70
91
  : (options.client ?? new Octokit().rest);
71
92
  this.msg =
72
93
  options.msg ??
73
- ((key, value) => (value === null ? `delete ${key}` : `update ${key}`));
94
+ ((key, value) =>
95
+ value === null ? `delete ${key} [skip ci]` : `update ${key} [skip ci]`);
96
+ this.batchMsg =
97
+ options.batchMsg ??
98
+ ((op, paths) => {
99
+ const n = paths.length;
100
+ if (op === "clear") return `clear: remove ${n} files [skip ci]`;
101
+ return `batch ${op} ${n} files [skip ci]`;
102
+ });
74
103
  this.enableClear = options.enableClear ?? false;
75
104
  this.prefix = options.prefix ?? "";
76
105
  this.suffix = options.suffix ?? "";
@@ -319,10 +348,11 @@ export default class KeyvGithub
319
348
  this.toPath(key),
320
349
  String(value),
321
350
  ]);
351
+ const paths = entries.map(([p]) => p);
322
352
  const message =
323
353
  entries.length === 1
324
354
  ? this.msg(entries[0]![0], entries[0]![1])
325
- : `batch update ${entries.length} files`;
355
+ : this.batchMsg("set", paths);
326
356
  await this._batchCommit({ set: entries, message });
327
357
  }
328
358
 
@@ -361,7 +391,7 @@ export default class KeyvGithub
361
391
  const message =
362
392
  toDelete.length === 1
363
393
  ? this.msg(toDelete[0]!, null)
364
- : `batch delete ${toDelete.length} files`;
394
+ : this.batchMsg("delete", toDelete);
365
395
  await this._batchCommit({ delete: toDelete, message });
366
396
  return true;
367
397
  }
@@ -397,7 +427,7 @@ export default class KeyvGithub
397
427
  if (allPaths.length > 0) {
398
428
  await this._batchCommit({
399
429
  delete: allPaths,
400
- message: `clear: remove ${allPaths.length} files`,
430
+ message: this.batchMsg("clear", allPaths),
401
431
  });
402
432
  }
403
433
  }