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 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
- npm install gh-as-db
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
 
@@ -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;
@@ -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
@@ -2,4 +2,4 @@ export { GitHubDB } from "./ui/github-db.js";
2
2
  export { Collection } from "./ui/collection.js";
3
3
  export { Transaction } from "./ui/transaction.js";
4
4
  export * from "./core/types.js";
5
- export declare const version = "1.2.0";
5
+ export declare const version = "1.3.0";
package/dist/index.js CHANGED
@@ -2,4 +2,4 @@ export { GitHubDB } from "./ui/github-db.js";
2
2
  export { Collection } from "./ui/collection.js";
3
3
  export { Transaction } from "./ui/transaction.js";
4
4
  export * from "./core/types.js";
5
- export const version = "1.2.0";
5
+ export const version = "1.3.0";
@@ -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
  }
File without changes
@@ -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.2.0",
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
+ }