gh-as-db 1.2.0 โ 1.3.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 +47 -1
- package/dist/core/types.d.ts +10 -0
- package/dist/core/types.js +8 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/infrastructure/github-storage.d.ts +1 -0
- package/dist/infrastructure/github-storage.js +74 -25
- package/dist/ui/cli/index.js +0 -0
- package/dist/ui/github-db.d.ts +2 -1
- package/package.json +7 -8
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ Use a private GitHub repository as a database for your application. `gh-as-db` p
|
|
|
11
11
|
- ๐ **Secure**: Designed for private repositories using Personal Access Tokens (PAT).
|
|
12
12
|
- ๐ **Performance**: Built-in in-memory caching and auto-indexing for fast local queries.
|
|
13
13
|
- ๐ก๏ธ **Concurrency**: Optimistic locking using Git SHAs to prevent data loss.
|
|
14
|
+
- ๐ **Retry & Rate Limits**: Automatic retries with exponential backoff for transient errors and GitHub rate limits.
|
|
14
15
|
- ๐ **Transactions**: Group multiple operations into a single atomic Git commit.
|
|
15
16
|
- ๐ **Sharding**: One-file-per-document storage strategy for massive collections.
|
|
16
17
|
- ๐งฉ **Middleware**: Extensible hooks for data validation or transformation.
|
|
@@ -22,7 +23,7 @@ Use a private GitHub repository as a database for your application. `gh-as-db` p
|
|
|
22
23
|
## Installation
|
|
23
24
|
|
|
24
25
|
```bash
|
|
25
|
-
|
|
26
|
+
pnpm add gh-as-db
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
## Quick Start
|
|
@@ -85,6 +86,7 @@ const db = new GitHubDB({
|
|
|
85
86
|
owner: string; // Repository owner
|
|
86
87
|
repo: string; // Repository name
|
|
87
88
|
cacheTTL?: number; // Optional: Cache TTL in ms. Default is 0 (strict consistency).
|
|
89
|
+
retry?: RetryConfig | false; // Optional: Retry config, or false to disable. See below.
|
|
88
90
|
});
|
|
89
91
|
```
|
|
90
92
|
|
|
@@ -193,6 +195,49 @@ const users = db.collection<User>('users', {
|
|
|
193
195
|
- ๐ **Scalability**: Avoids GitHub's file size limits and reduces merge conflicts.
|
|
194
196
|
- ๐งน **Cleanliness**: Better organization for repositories with thousands of documents.
|
|
195
197
|
|
|
198
|
+
### Retry & Rate Limit Handling
|
|
199
|
+
|
|
200
|
+
All GitHub API calls are automatically retried on transient errors (429 rate limits, 500/502/503 server errors) with exponential backoff. If GitHub returns a `Retry-After` header, it is respected.
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
const db = new GitHubDB({
|
|
204
|
+
accessToken: process.env.GITHUB_TOKEN,
|
|
205
|
+
owner: 'your-username',
|
|
206
|
+
repo: 'my-data-repo',
|
|
207
|
+
retry: {
|
|
208
|
+
maxRetries: 3, // Default: 3
|
|
209
|
+
baseDelay: 1000, // Default: 1000ms
|
|
210
|
+
maxDelay: 10000, // Default: 10000ms
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
To disable retries entirely:
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
const db = new GitHubDB({
|
|
219
|
+
// ...
|
|
220
|
+
retry: false,
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Non-transient errors (401, 403, 404) are thrown immediately without retrying. Concurrency conflicts (409) still throw `ConcurrencyError` immediately. If rate limit retries are exhausted, a `RateLimitError` is thrown.
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
import { RateLimitError, ConcurrencyError } from 'gh-as-db';
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await users.create({ id: '1', name: 'Alice' });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error instanceof RateLimitError) {
|
|
233
|
+
console.log('Rate limited, retry after:', error.retryAfter);
|
|
234
|
+
}
|
|
235
|
+
if (error instanceof ConcurrencyError) {
|
|
236
|
+
console.log('Conflict, re-read and retry');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
196
241
|
## CLI Usage
|
|
197
242
|
|
|
198
243
|
`gh-as-db` comes with a CLI tool to help you manage your repository.
|
|
@@ -221,6 +266,7 @@ For small projects, side-projects, or internal tools, setting up a database serv
|
|
|
221
266
|
- **Write-Through**: Updates the local cache immediately after a write, preventing 404s during redirects.
|
|
222
267
|
- **Indexing**: Automatic in-memory indexing on all fields makes querying fast even as data grows.
|
|
223
268
|
- **Optimistic Concurrency**: Uses Git SHAs to ensure that you don't overwrite changes made by another client.
|
|
269
|
+
- **Automatic Retries**: Transient failures and rate limits are handled transparently with exponential backoff.
|
|
224
270
|
|
|
225
271
|
## License
|
|
226
272
|
|
package/dist/core/types.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
export interface RetryConfig {
|
|
2
|
+
maxRetries?: number;
|
|
3
|
+
baseDelay?: number;
|
|
4
|
+
maxDelay?: number;
|
|
5
|
+
}
|
|
1
6
|
export interface GitHubDBConfig {
|
|
2
7
|
accessToken: string;
|
|
3
8
|
owner: string;
|
|
4
9
|
repo: string;
|
|
5
10
|
branch?: string;
|
|
6
11
|
cacheTTL?: number;
|
|
12
|
+
retry?: RetryConfig | false;
|
|
7
13
|
}
|
|
8
14
|
export type Schema = Record<string, any>;
|
|
9
15
|
export type MiddlewareOperation = "create" | "update" | "read" | "delete";
|
|
@@ -39,6 +45,10 @@ export declare class ConcurrencyError extends Error {
|
|
|
39
45
|
readonly path: string;
|
|
40
46
|
constructor(path: string);
|
|
41
47
|
}
|
|
48
|
+
export declare class RateLimitError extends Error {
|
|
49
|
+
readonly retryAfter?: number | undefined;
|
|
50
|
+
constructor(retryAfter?: number | undefined);
|
|
51
|
+
}
|
|
42
52
|
export interface StorageResponse<T> {
|
|
43
53
|
data: T;
|
|
44
54
|
sha: string;
|
package/dist/core/types.js
CHANGED
|
@@ -6,3 +6,11 @@ export class ConcurrencyError extends Error {
|
|
|
6
6
|
this.name = "ConcurrencyError";
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
+
export class RateLimitError extends Error {
|
|
10
|
+
retryAfter;
|
|
11
|
+
constructor(retryAfter) {
|
|
12
|
+
super(`GitHub API rate limit exceeded${retryAfter ? `. Retry after ${retryAfter}s` : ""}`);
|
|
13
|
+
this.retryAfter = retryAfter;
|
|
14
|
+
this.name = "RateLimitError";
|
|
15
|
+
}
|
|
16
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export declare class GitHubStorageProvider implements IStorageProvider {
|
|
|
7
7
|
private staleCache;
|
|
8
8
|
private readonly DEFAULT_TTL;
|
|
9
9
|
constructor(config: GitHubDBConfig, cache?: ICacheProvider);
|
|
10
|
+
private retryWithBackoff;
|
|
10
11
|
testConnection(): Promise<boolean>;
|
|
11
12
|
exists(path: string): Promise<boolean>;
|
|
12
13
|
readJson<T>(path: string): Promise<StorageResponse<T>>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Octokit } from "@octokit/rest";
|
|
2
|
-
import { ConcurrencyError, } from "../core/types.js";
|
|
2
|
+
import { ConcurrencyError, RateLimitError, } from "../core/types.js";
|
|
3
3
|
import { MemoryCacheProvider } from "./cache-provider.js";
|
|
4
4
|
export class GitHubStorageProvider {
|
|
5
5
|
config;
|
|
@@ -14,12 +14,58 @@ export class GitHubStorageProvider {
|
|
|
14
14
|
});
|
|
15
15
|
this.cache = cache || new MemoryCacheProvider();
|
|
16
16
|
}
|
|
17
|
+
async retryWithBackoff(fn) {
|
|
18
|
+
if (this.config.retry === false)
|
|
19
|
+
return fn();
|
|
20
|
+
const { maxRetries = 3, baseDelay = 1000, maxDelay = 10000, } = this.config.retry ?? {};
|
|
21
|
+
let lastError;
|
|
22
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
23
|
+
try {
|
|
24
|
+
return await fn();
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
lastError = error;
|
|
28
|
+
const status = error?.status;
|
|
29
|
+
// Non-retryable: 409 (concurrency), 4xx client errors (except 429)
|
|
30
|
+
if (status === 409)
|
|
31
|
+
throw error;
|
|
32
|
+
if (status && status < 500 && status !== 429)
|
|
33
|
+
throw error;
|
|
34
|
+
// Errors without a status code (non-HTTP errors) โ rethrow
|
|
35
|
+
if (!status)
|
|
36
|
+
throw error;
|
|
37
|
+
// Last attempt โ don't wait, just break
|
|
38
|
+
if (attempt === maxRetries)
|
|
39
|
+
break;
|
|
40
|
+
// Calculate delay
|
|
41
|
+
let delay;
|
|
42
|
+
if (status === 429 && error.response?.headers?.["retry-after"]) {
|
|
43
|
+
delay =
|
|
44
|
+
parseInt(error.response.headers["retry-after"], 10) * 1000;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
delay = baseDelay * Math.pow(2, attempt);
|
|
48
|
+
// Add jitter to avoid thundering herd on simultaneous retries
|
|
49
|
+
delay = delay * (0.5 + Math.random() * 0.5);
|
|
50
|
+
}
|
|
51
|
+
// Cap all delays (including Retry-After) against maxDelay
|
|
52
|
+
delay = Math.min(delay, maxDelay);
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Exhausted retries on 429 โ RateLimitError
|
|
57
|
+
if (lastError.status === 429) {
|
|
58
|
+
const retryAfter = lastError.response?.headers?.["retry-after"];
|
|
59
|
+
throw new RateLimitError(retryAfter ? parseInt(retryAfter, 10) : undefined);
|
|
60
|
+
}
|
|
61
|
+
throw lastError;
|
|
62
|
+
}
|
|
17
63
|
async testConnection() {
|
|
18
64
|
try {
|
|
19
|
-
await this.octokit.repos.get({
|
|
65
|
+
await this.retryWithBackoff(() => this.octokit.repos.get({
|
|
20
66
|
owner: this.config.owner,
|
|
21
67
|
repo: this.config.repo,
|
|
22
|
-
});
|
|
68
|
+
}));
|
|
23
69
|
return true;
|
|
24
70
|
}
|
|
25
71
|
catch (error) {
|
|
@@ -31,11 +77,11 @@ export class GitHubStorageProvider {
|
|
|
31
77
|
return true;
|
|
32
78
|
}
|
|
33
79
|
try {
|
|
34
|
-
await this.octokit.repos.getContent({
|
|
80
|
+
await this.retryWithBackoff(() => this.octokit.repos.getContent({
|
|
35
81
|
owner: this.config.owner,
|
|
36
82
|
repo: this.config.repo,
|
|
37
83
|
path,
|
|
38
|
-
});
|
|
84
|
+
}));
|
|
39
85
|
return true;
|
|
40
86
|
}
|
|
41
87
|
catch (error) {
|
|
@@ -54,12 +100,12 @@ export class GitHubStorageProvider {
|
|
|
54
100
|
// 2. Check stale cache for conditional request
|
|
55
101
|
const stale = this.staleCache.get(path);
|
|
56
102
|
try {
|
|
57
|
-
const response = await this.octokit.repos.getContent({
|
|
103
|
+
const response = await this.retryWithBackoff(() => this.octokit.repos.getContent({
|
|
58
104
|
owner: this.config.owner,
|
|
59
105
|
repo: this.config.repo,
|
|
60
106
|
path,
|
|
61
107
|
headers: stale ? { "if-none-match": `"${stale.sha}"` } : {},
|
|
62
|
-
});
|
|
108
|
+
}));
|
|
63
109
|
if (Array.isArray(response.data)) {
|
|
64
110
|
throw new Error("Path is a directory, not a file");
|
|
65
111
|
}
|
|
@@ -102,11 +148,11 @@ export class GitHubStorageProvider {
|
|
|
102
148
|
}
|
|
103
149
|
else {
|
|
104
150
|
try {
|
|
105
|
-
const existing = await this.octokit.repos.getContent({
|
|
151
|
+
const existing = await this.retryWithBackoff(() => this.octokit.repos.getContent({
|
|
106
152
|
owner: this.config.owner,
|
|
107
153
|
repo: this.config.repo,
|
|
108
154
|
path,
|
|
109
|
-
});
|
|
155
|
+
}));
|
|
110
156
|
if (!Array.isArray(existing.data) && "sha" in existing.data) {
|
|
111
157
|
internalSha = existing.data.sha;
|
|
112
158
|
}
|
|
@@ -118,8 +164,11 @@ export class GitHubStorageProvider {
|
|
|
118
164
|
}
|
|
119
165
|
}
|
|
120
166
|
}
|
|
167
|
+
// Note: internalSha is resolved before the retry wrapper. If a write fails
|
|
168
|
+
// with a 5xx after GitHub has already committed the file (e.g. network timeout),
|
|
169
|
+
// the retry will reuse the stale sha and may fail with a 409.
|
|
121
170
|
try {
|
|
122
|
-
const response = await this.octokit.repos.createOrUpdateFileContents({
|
|
171
|
+
const response = await this.retryWithBackoff(() => this.octokit.repos.createOrUpdateFileContents({
|
|
123
172
|
owner: this.config.owner,
|
|
124
173
|
repo: this.config.repo,
|
|
125
174
|
path,
|
|
@@ -128,7 +177,7 @@ export class GitHubStorageProvider {
|
|
|
128
177
|
.map((b) => String.fromCharCode(b))
|
|
129
178
|
.join("")),
|
|
130
179
|
sha: internalSha,
|
|
131
|
-
});
|
|
180
|
+
}));
|
|
132
181
|
const newSha = response.data.content?.sha || "";
|
|
133
182
|
const result = { data: content, sha: newSha };
|
|
134
183
|
const ttl = this.config.cacheTTL ?? this.DEFAULT_TTL;
|
|
@@ -150,18 +199,18 @@ export class GitHubStorageProvider {
|
|
|
150
199
|
const branch = this.config.branch || "main";
|
|
151
200
|
try {
|
|
152
201
|
// 1. Get the current head SHA
|
|
153
|
-
const { data: ref } = await this.octokit.git.getRef({
|
|
202
|
+
const { data: ref } = await this.retryWithBackoff(() => this.octokit.git.getRef({
|
|
154
203
|
owner: this.config.owner,
|
|
155
204
|
repo: this.config.repo,
|
|
156
205
|
ref: `heads/${branch}`,
|
|
157
|
-
});
|
|
206
|
+
}));
|
|
158
207
|
const latestCommitSha = ref.object.sha;
|
|
159
208
|
// 2. Get the tree SHA of the latest commit
|
|
160
|
-
const { data: latestCommit } = await this.octokit.git.getCommit({
|
|
209
|
+
const { data: latestCommit } = await this.retryWithBackoff(() => this.octokit.git.getCommit({
|
|
161
210
|
owner: this.config.owner,
|
|
162
211
|
repo: this.config.repo,
|
|
163
212
|
commit_sha: latestCommitSha,
|
|
164
|
-
});
|
|
213
|
+
}));
|
|
165
214
|
const baseTreeSha = latestCommit.tree.sha;
|
|
166
215
|
// 3. Create a new tree with multiple files
|
|
167
216
|
const treeEntries = changes.map((change) => {
|
|
@@ -180,27 +229,27 @@ export class GitHubStorageProvider {
|
|
|
180
229
|
content: JSON.stringify(change.content, null, 2),
|
|
181
230
|
};
|
|
182
231
|
});
|
|
183
|
-
const { data: newTree } = await this.octokit.git.createTree({
|
|
232
|
+
const { data: newTree } = await this.retryWithBackoff(() => this.octokit.git.createTree({
|
|
184
233
|
owner: this.config.owner,
|
|
185
234
|
repo: this.config.repo,
|
|
186
235
|
base_tree: baseTreeSha,
|
|
187
236
|
tree: treeEntries,
|
|
188
|
-
});
|
|
237
|
+
}));
|
|
189
238
|
// 4. Create a new commit
|
|
190
|
-
const { data: newCommit } = await this.octokit.git.createCommit({
|
|
239
|
+
const { data: newCommit } = await this.retryWithBackoff(() => this.octokit.git.createCommit({
|
|
191
240
|
owner: this.config.owner,
|
|
192
241
|
repo: this.config.repo,
|
|
193
242
|
message,
|
|
194
243
|
tree: newTree.sha,
|
|
195
244
|
parents: [latestCommitSha],
|
|
196
|
-
});
|
|
245
|
+
}));
|
|
197
246
|
// 5. Update the reference
|
|
198
|
-
await this.octokit.git.updateRef({
|
|
247
|
+
await this.retryWithBackoff(() => this.octokit.git.updateRef({
|
|
199
248
|
owner: this.config.owner,
|
|
200
249
|
repo: this.config.repo,
|
|
201
250
|
ref: `heads/${branch}`,
|
|
202
251
|
sha: newCommit.sha,
|
|
203
|
-
});
|
|
252
|
+
}));
|
|
204
253
|
// 6. Update cache for all involved files
|
|
205
254
|
const ttl = this.config.cacheTTL ?? this.DEFAULT_TTL;
|
|
206
255
|
for (const entry of newTree.tree) {
|
|
@@ -223,23 +272,23 @@ export class GitHubStorageProvider {
|
|
|
223
272
|
}
|
|
224
273
|
}
|
|
225
274
|
async deleteFile(path, message, sha) {
|
|
226
|
-
await this.octokit.repos.deleteFile({
|
|
275
|
+
await this.retryWithBackoff(() => this.octokit.repos.deleteFile({
|
|
227
276
|
owner: this.config.owner,
|
|
228
277
|
repo: this.config.repo,
|
|
229
278
|
path,
|
|
230
279
|
message,
|
|
231
280
|
sha,
|
|
232
|
-
});
|
|
281
|
+
}));
|
|
233
282
|
this.cache.delete(path);
|
|
234
283
|
this.staleCache.delete(path);
|
|
235
284
|
}
|
|
236
285
|
async listDirectory(path) {
|
|
237
286
|
try {
|
|
238
|
-
const response = await this.octokit.repos.getContent({
|
|
287
|
+
const response = await this.retryWithBackoff(() => this.octokit.repos.getContent({
|
|
239
288
|
owner: this.config.owner,
|
|
240
289
|
repo: this.config.repo,
|
|
241
290
|
path,
|
|
242
|
-
});
|
|
291
|
+
}));
|
|
243
292
|
if (!Array.isArray(response.data)) {
|
|
244
293
|
throw new Error("Path is not a directory");
|
|
245
294
|
}
|
package/dist/ui/cli/index.js
CHANGED
|
File without changes
|
package/dist/ui/github-db.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GitHubDBConfig, IStorageProvider, Middleware, Schema, Validator } from "../core/types.js";
|
|
1
|
+
import { GitHubDBConfig, IStorageProvider, Middleware, Schema, StorageStrategy, Validator } from "../core/types.js";
|
|
2
2
|
import { Collection } from "./collection.js";
|
|
3
3
|
import { Transaction } from "./transaction.js";
|
|
4
4
|
export declare class GitHubDB {
|
|
@@ -9,6 +9,7 @@ export declare class GitHubDB {
|
|
|
9
9
|
collection<T extends Schema>(name: string, options?: {
|
|
10
10
|
middleware?: Middleware<T>[];
|
|
11
11
|
validator?: Validator<T>;
|
|
12
|
+
strategy?: StorageStrategy;
|
|
12
13
|
}): Collection<T>;
|
|
13
14
|
transaction(fn: (tx: Transaction) => Promise<void>, message?: string): Promise<string>;
|
|
14
15
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gh-as-db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Use a private GitHub repository as a database for your application.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,12 +20,6 @@
|
|
|
20
20
|
"README.md",
|
|
21
21
|
"LICENSE"
|
|
22
22
|
],
|
|
23
|
-
"scripts": {
|
|
24
|
-
"test": "vitest",
|
|
25
|
-
"test:run": "vitest run",
|
|
26
|
-
"build": "tsc",
|
|
27
|
-
"prepublishOnly": "npm run build"
|
|
28
|
-
},
|
|
29
23
|
"keywords": [
|
|
30
24
|
"github",
|
|
31
25
|
"database",
|
|
@@ -60,5 +54,10 @@
|
|
|
60
54
|
"chalk": "^5.4.1",
|
|
61
55
|
"commander": "^13.1.0",
|
|
62
56
|
"enquirer": "^2.4.1"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"test": "vitest",
|
|
60
|
+
"test:run": "vitest run",
|
|
61
|
+
"build": "tsc"
|
|
63
62
|
}
|
|
64
|
-
}
|
|
63
|
+
}
|