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.
- package/README.md +60 -0
- package/bun.lock +347 -17
- package/demo-best-practice.ts +100 -0
- package/dist/index.d.mts +19 -1
- package/dist/index.mjs +16 -4
- package/package.json +3 -1
- package/src/index.test.ts +5 -5
- package/src/index.ts +35 -5
|
@@ -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
|
-
/**
|
|
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
|
|
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) :
|
|
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:
|
|
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
|
+
"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
|
|
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
|
-
/**
|
|
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) =>
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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:
|
|
430
|
+
message: this.batchMsg("clear", allPaths),
|
|
401
431
|
});
|
|
402
432
|
}
|
|
403
433
|
}
|