gh-as-db 1.1.1 โ†’ 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,14 +11,19 @@ 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.
15
+ - ๐Ÿ”— **Transactions**: Group multiple operations into a single atomic Git commit.
16
+ - ๐Ÿ“ **Sharding**: One-file-per-document storage strategy for massive collections.
14
17
  - ๐Ÿงฉ **Middleware**: Extensible hooks for data validation or transformation.
18
+ - ๐Ÿ”€ **Validation**: Pluggable schema validation support (Zod, etc.).
19
+ - ๐ŸŒ **Edge Ready**: Fully compatible with Vercel Edge and Cloudflare Workers.
15
20
  - โŒจ๏ธ **CLI Tool**: Initialize and manage your "database" from the terminal.
16
21
  - ๐Ÿ“ฆ **TypeScript**: Fully typed for a great developer experience.
17
22
 
18
23
  ## Installation
19
24
 
20
25
  ```bash
21
- npm install gh-as-db
26
+ pnpm add gh-as-db
22
27
  ```
23
28
 
24
29
  ## Quick Start
@@ -58,6 +63,15 @@ const results = await users.find({
58
63
  { field: 'name', operator: 'eq', value: 'John Doe' }
59
64
  ]
60
65
  });
66
+
67
+ // Use Transactions for atomic multi-collection updates
68
+ await db.transaction(async (tx) => {
69
+ const usersTx = tx.collection('users');
70
+ const logsTx = tx.collection('logs');
71
+
72
+ await usersTx.update('1', { name: 'Updated Name' });
73
+ await logsTx.create({ id: 'log-1', action: 'Update user' });
74
+ }, 'Atomic update of user and logs');
61
75
  ```
62
76
 
63
77
  ## API Reference
@@ -72,6 +86,7 @@ const db = new GitHubDB({
72
86
  owner: string; // Repository owner
73
87
  repo: string; // Repository name
74
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.
75
90
  });
76
91
  ```
77
92
 
@@ -131,6 +146,98 @@ const users = db.collection<User>('users', {
131
146
  });
132
147
  ```
133
148
 
149
+ ### Schema Validation
150
+
151
+ You can use any validation library (Zod, Valibot, etc.) by providing a `validator` object.
152
+
153
+ ```typescript
154
+ import { z } from 'zod';
155
+
156
+ const userSchema = z.object({
157
+ id: z.string(),
158
+ name: z.string().min(3),
159
+ email: z.string().email(),
160
+ });
161
+
162
+ const users = db.collection<User>('users', {
163
+ validator: {
164
+ validate: (data) => userSchema.parseAsync(data),
165
+ }
166
+ });
167
+ ```
168
+
169
+ ### Transactions & Batching
170
+
171
+ Transactions allow you to group multiple operations across different collections into a **single Git commit**. This reduces API calls and ensures that either all operations succeed or none are committed.
172
+
173
+ ```typescript
174
+ const commitSha = await db.transaction(async (tx) => {
175
+ const posts = tx.collection('posts');
176
+ const count = tx.collection('stats');
177
+
178
+ await posts.create({ id: 'p1', title: 'New Post' });
179
+ await count.update('total', { value: 101 });
180
+ }, 'Create post and increment counter');
181
+ ```
182
+
183
+ ### Storage Strategies (Sharding)
184
+
185
+ By default, `gh-as-db` stores the entire collection in a single JSON file (`name.json`). For large collections, you can use the `sharded` strategy, which stores **one file per document** (`name/id.json`).
186
+
187
+ ```typescript
188
+ const users = db.collection<User>('users', {
189
+ strategy: 'sharded'
190
+ });
191
+ ```
192
+
193
+ **Why use Sharding?**
194
+ - ๐Ÿš€ **Performance**: `findById` reads only the specific file, which is much faster than loading a massive JSON array.
195
+ - ๐Ÿ“ˆ **Scalability**: Avoids GitHub's file size limits and reduces merge conflicts.
196
+ - ๐Ÿงน **Cleanliness**: Better organization for repositories with thousands of documents.
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
+
134
241
  ## CLI Usage
135
242
 
136
243
  `gh-as-db` comes with a CLI tool to help you manage your repository.
@@ -159,6 +266,7 @@ For small projects, side-projects, or internal tools, setting up a database serv
159
266
  - **Write-Through**: Updates the local cache immediately after a write, preventing 404s during redirects.
160
267
  - **Indexing**: Automatic in-memory indexing on all fields makes querying fast even as data grows.
161
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.
162
270
 
163
271
  ## License
164
272
 
@@ -1,8 +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;
10
+ branch?: string;
5
11
  cacheTTL?: number;
12
+ retry?: RetryConfig | false;
6
13
  }
7
14
  export type Schema = Record<string, any>;
8
15
  export type MiddlewareOperation = "create" | "update" | "read" | "delete";
@@ -38,13 +45,32 @@ export declare class ConcurrencyError extends Error {
38
45
  readonly path: string;
39
46
  constructor(path: string);
40
47
  }
48
+ export declare class RateLimitError extends Error {
49
+ readonly retryAfter?: number | undefined;
50
+ constructor(retryAfter?: number | undefined);
51
+ }
41
52
  export interface StorageResponse<T> {
42
53
  data: T;
43
54
  sha: string;
44
55
  }
56
+ export interface Validator<T> {
57
+ validate: (data: unknown) => Promise<T> | T;
58
+ }
59
+ export interface CommitChange {
60
+ path: string;
61
+ content: any;
62
+ }
63
+ export type StorageStrategy = "single-file" | "sharded";
45
64
  export interface IStorageProvider {
46
65
  testConnection(): Promise<boolean>;
47
66
  exists(path: string): Promise<boolean>;
48
67
  readJson<T>(path: string): Promise<StorageResponse<T>>;
49
68
  writeJson<T>(path: string, content: T, message: string, sha?: string): Promise<string>;
69
+ deleteFile(path: string, message: string, sha: string): Promise<void>;
70
+ listDirectory(path: string): Promise<{
71
+ path: string;
72
+ sha: string;
73
+ type: "file" | "dir";
74
+ }[]>;
75
+ commit(changes: CommitChange[], message: string): Promise<string>;
50
76
  }
@@ -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
@@ -1,4 +1,5 @@
1
1
  export { GitHubDB } from "./ui/github-db.js";
2
2
  export { Collection } from "./ui/collection.js";
3
+ export { Transaction } from "./ui/transaction.js";
3
4
  export * from "./core/types.js";
4
- export declare const version = "1.0.1";
5
+ export declare const version = "1.3.0";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { GitHubDB } from "./ui/github-db.js";
2
2
  export { Collection } from "./ui/collection.js";
3
+ export { Transaction } from "./ui/transaction.js";
3
4
  export * from "./core/types.js";
4
- export const version = "1.0.1";
5
+ export const version = "1.3.0";
@@ -1,4 +1,4 @@
1
- import { GitHubDBConfig, IStorageProvider, StorageResponse } from "../core/types.js";
1
+ import { GitHubDBConfig, IStorageProvider, StorageResponse, CommitChange } from "../core/types.js";
2
2
  import { ICacheProvider } from "./cache-provider.js";
3
3
  export declare class GitHubStorageProvider implements IStorageProvider {
4
4
  private config;
@@ -7,8 +7,16 @@ 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>>;
13
14
  writeJson<T>(path: string, content: T, message: string, sha?: string): Promise<string>;
15
+ commit(changes: CommitChange[], message: string): Promise<string>;
16
+ deleteFile(path: string, message: string, sha: string): Promise<void>;
17
+ listDirectory(path: string): Promise<{
18
+ path: string;
19
+ sha: string;
20
+ type: "file" | "dir";
21
+ }[]>;
14
22
  }
@@ -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,19 +100,25 @@ 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
  }
66
112
  if (!("content" in response.data) || !("sha" in response.data)) {
67
113
  throw new Error("No content or SHA in response");
68
114
  }
69
- const content = Buffer.from(response.data.content, "base64").toString("utf-8");
115
+ const binary = atob(response.data.content.replace(/\s/g, ""));
116
+ const bytes = new Uint8Array(binary.length);
117
+ for (let i = 0; i < binary.length; i++) {
118
+ bytes[i] = binary.charCodeAt(i);
119
+ }
120
+ const decoder = new TextDecoder("utf-8");
121
+ const content = decoder.decode(bytes);
70
122
  const result = {
71
123
  data: JSON.parse(content),
72
124
  sha: response.data.sha,
@@ -96,11 +148,11 @@ export class GitHubStorageProvider {
96
148
  }
97
149
  else {
98
150
  try {
99
- const existing = await this.octokit.repos.getContent({
151
+ const existing = await this.retryWithBackoff(() => this.octokit.repos.getContent({
100
152
  owner: this.config.owner,
101
153
  repo: this.config.repo,
102
154
  path,
103
- });
155
+ }));
104
156
  if (!Array.isArray(existing.data) && "sha" in existing.data) {
105
157
  internalSha = existing.data.sha;
106
158
  }
@@ -112,15 +164,20 @@ export class GitHubStorageProvider {
112
164
  }
113
165
  }
114
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.
115
170
  try {
116
- const response = await this.octokit.repos.createOrUpdateFileContents({
171
+ const response = await this.retryWithBackoff(() => this.octokit.repos.createOrUpdateFileContents({
117
172
  owner: this.config.owner,
118
173
  repo: this.config.repo,
119
174
  path,
120
175
  message,
121
- content: Buffer.from(JSON.stringify(content, null, 2)).toString("base64"),
176
+ content: btoa(Array.from(new TextEncoder().encode(JSON.stringify(content, null, 2)))
177
+ .map((b) => String.fromCharCode(b))
178
+ .join("")),
122
179
  sha: internalSha,
123
- });
180
+ }));
124
181
  const newSha = response.data.content?.sha || "";
125
182
  const result = { data: content, sha: newSha };
126
183
  const ttl = this.config.cacheTTL ?? this.DEFAULT_TTL;
@@ -135,4 +192,117 @@ export class GitHubStorageProvider {
135
192
  throw error;
136
193
  }
137
194
  }
195
+ async commit(changes, message) {
196
+ if (changes.length === 0) {
197
+ throw new Error("No changes to commit");
198
+ }
199
+ const branch = this.config.branch || "main";
200
+ try {
201
+ // 1. Get the current head SHA
202
+ const { data: ref } = await this.retryWithBackoff(() => this.octokit.git.getRef({
203
+ owner: this.config.owner,
204
+ repo: this.config.repo,
205
+ ref: `heads/${branch}`,
206
+ }));
207
+ const latestCommitSha = ref.object.sha;
208
+ // 2. Get the tree SHA of the latest commit
209
+ const { data: latestCommit } = await this.retryWithBackoff(() => this.octokit.git.getCommit({
210
+ owner: this.config.owner,
211
+ repo: this.config.repo,
212
+ commit_sha: latestCommitSha,
213
+ }));
214
+ const baseTreeSha = latestCommit.tree.sha;
215
+ // 3. Create a new tree with multiple files
216
+ const treeEntries = changes.map((change) => {
217
+ if (change.content === null) {
218
+ return {
219
+ path: change.path,
220
+ mode: "100644",
221
+ type: "blob",
222
+ sha: null,
223
+ };
224
+ }
225
+ return {
226
+ path: change.path,
227
+ mode: "100644",
228
+ type: "blob",
229
+ content: JSON.stringify(change.content, null, 2),
230
+ };
231
+ });
232
+ const { data: newTree } = await this.retryWithBackoff(() => this.octokit.git.createTree({
233
+ owner: this.config.owner,
234
+ repo: this.config.repo,
235
+ base_tree: baseTreeSha,
236
+ tree: treeEntries,
237
+ }));
238
+ // 4. Create a new commit
239
+ const { data: newCommit } = await this.retryWithBackoff(() => this.octokit.git.createCommit({
240
+ owner: this.config.owner,
241
+ repo: this.config.repo,
242
+ message,
243
+ tree: newTree.sha,
244
+ parents: [latestCommitSha],
245
+ }));
246
+ // 5. Update the reference
247
+ await this.retryWithBackoff(() => this.octokit.git.updateRef({
248
+ owner: this.config.owner,
249
+ repo: this.config.repo,
250
+ ref: `heads/${branch}`,
251
+ sha: newCommit.sha,
252
+ }));
253
+ // 6. Update cache for all involved files
254
+ const ttl = this.config.cacheTTL ?? this.DEFAULT_TTL;
255
+ for (const entry of newTree.tree) {
256
+ if (entry.path && entry.sha) {
257
+ const change = changes.find((c) => c.path === entry.path);
258
+ if (change) {
259
+ const result = { data: change.content, sha: entry.sha };
260
+ this.cache.set(entry.path, result, ttl);
261
+ this.staleCache.set(entry.path, result);
262
+ }
263
+ }
264
+ }
265
+ return newCommit.sha;
266
+ }
267
+ catch (error) {
268
+ if (error.status === 409) {
269
+ throw new ConcurrencyError("batch-commit");
270
+ }
271
+ throw error;
272
+ }
273
+ }
274
+ async deleteFile(path, message, sha) {
275
+ await this.retryWithBackoff(() => this.octokit.repos.deleteFile({
276
+ owner: this.config.owner,
277
+ repo: this.config.repo,
278
+ path,
279
+ message,
280
+ sha,
281
+ }));
282
+ this.cache.delete(path);
283
+ this.staleCache.delete(path);
284
+ }
285
+ async listDirectory(path) {
286
+ try {
287
+ const response = await this.retryWithBackoff(() => this.octokit.repos.getContent({
288
+ owner: this.config.owner,
289
+ repo: this.config.repo,
290
+ path,
291
+ }));
292
+ if (!Array.isArray(response.data)) {
293
+ throw new Error("Path is not a directory");
294
+ }
295
+ return response.data.map((item) => ({
296
+ path: item.path,
297
+ sha: item.sha,
298
+ type: item.type,
299
+ }));
300
+ }
301
+ catch (error) {
302
+ if (error.status === 404) {
303
+ return [];
304
+ }
305
+ throw error;
306
+ }
307
+ }
138
308
  }
@@ -0,0 +1,25 @@
1
+ import { CommitChange, IStorageProvider, StorageResponse } from "../core/types.js";
2
+ /**
3
+ * A decorator for IStorageProvider that buffers writes in memory.
4
+ * Used during transactions to group multiple operations into a single commit.
5
+ */
6
+ export declare class TransactionStorageProvider implements IStorageProvider {
7
+ private readonly baseStorage;
8
+ private pendingChanges;
9
+ constructor(baseStorage: IStorageProvider);
10
+ testConnection(): Promise<boolean>;
11
+ exists(path: string): Promise<boolean>;
12
+ readJson<T>(path: string): Promise<StorageResponse<T>>;
13
+ writeJson<T>(path: string, content: T, _message: string, _sha?: string): Promise<string>;
14
+ deleteFile(path: string, _message: string, _sha: string): Promise<void>;
15
+ listDirectory(path: string): Promise<{
16
+ path: string;
17
+ sha: string;
18
+ type: "file" | "dir";
19
+ }[]>;
20
+ commit(changes: CommitChange[], message: string): Promise<string>;
21
+ /**
22
+ * Returns all pending changes in this transaction.
23
+ */
24
+ getChanges(): CommitChange[];
25
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A decorator for IStorageProvider that buffers writes in memory.
3
+ * Used during transactions to group multiple operations into a single commit.
4
+ */
5
+ export class TransactionStorageProvider {
6
+ baseStorage;
7
+ pendingChanges = new Map();
8
+ constructor(baseStorage) {
9
+ this.baseStorage = baseStorage;
10
+ }
11
+ async testConnection() {
12
+ return this.baseStorage.testConnection();
13
+ }
14
+ async exists(path) {
15
+ if (this.pendingChanges.has(path)) {
16
+ return true;
17
+ }
18
+ return this.baseStorage.exists(path);
19
+ }
20
+ async readJson(path) {
21
+ const pending = this.pendingChanges.get(path);
22
+ if (pending !== undefined) {
23
+ return {
24
+ data: pending,
25
+ sha: "transaction-pending-sha",
26
+ };
27
+ }
28
+ return this.baseStorage.readJson(path);
29
+ }
30
+ async writeJson(path, content, _message, _sha) {
31
+ this.pendingChanges.set(path, content);
32
+ return "transaction-pending-sha";
33
+ }
34
+ async deleteFile(path, _message, _sha) {
35
+ this.pendingChanges.set(path, null);
36
+ }
37
+ async listDirectory(path) {
38
+ // For now, we delegate to base storage.
39
+ // In sharded mode, we might need to merge with pending changes.
40
+ return this.baseStorage.listDirectory(path);
41
+ }
42
+ async commit(changes, message) {
43
+ return this.baseStorage.commit(changes, message);
44
+ }
45
+ /**
46
+ * Returns all pending changes in this transaction.
47
+ */
48
+ getChanges() {
49
+ return Array.from(this.pendingChanges.entries()).map(([path, content]) => ({
50
+ path,
51
+ content,
52
+ }));
53
+ }
54
+ }
File without changes
@@ -1,14 +1,22 @@
1
- import { IStorageProvider, Middleware, QueryOptions, Schema } from "../core/types.js";
1
+ import { IStorageProvider, Middleware, QueryOptions, Schema, StorageStrategy, Validator } from "../core/types.js";
2
2
  export declare class Collection<T extends Schema> {
3
3
  readonly name: string;
4
4
  private readonly storage;
5
- private readonly middleware;
6
5
  private lastSha;
7
6
  private indexer;
8
7
  private items;
9
8
  private dataLoaded;
10
- constructor(name: string, storage: IStorageProvider, middleware?: Middleware<T>[]);
9
+ private middleware;
10
+ private validator?;
11
+ private strategy;
12
+ private shas;
13
+ constructor(name: string, storage: IStorageProvider, middlewareOrOptions?: Middleware<T>[] | {
14
+ middleware?: Middleware<T>[];
15
+ validator?: Validator<T>;
16
+ strategy?: StorageStrategy;
17
+ });
11
18
  private get path();
19
+ private getItemPath;
12
20
  create(item: T): Promise<T>;
13
21
  find(queryOrPredicate?: ((item: T) => boolean) | QueryOptions<T>): Promise<T[]>;
14
22
  private applyQueryOptions;
@@ -3,21 +3,42 @@ import { Indexer } from "../core/indexer.js";
3
3
  export class Collection {
4
4
  name;
5
5
  storage;
6
- middleware;
7
6
  lastSha;
8
7
  indexer = new Indexer();
9
8
  items = [];
10
9
  dataLoaded = false;
11
- constructor(name, storage, middleware = []) {
10
+ middleware;
11
+ validator;
12
+ strategy;
13
+ shas = new Map(); // path -> sha for sharded mode
14
+ constructor(name, storage, middlewareOrOptions) {
12
15
  this.name = name;
13
16
  this.storage = storage;
14
- this.middleware = middleware;
17
+ if (Array.isArray(middlewareOrOptions)) {
18
+ this.middleware = middlewareOrOptions;
19
+ this.strategy = "single-file";
20
+ }
21
+ else {
22
+ this.middleware = middlewareOrOptions?.middleware || [];
23
+ this.validator = middlewareOrOptions?.validator;
24
+ this.strategy = middlewareOrOptions?.strategy || "single-file";
25
+ }
15
26
  }
16
27
  get path() {
17
- return `${this.name}.json`;
28
+ return this.strategy === "sharded" ? `${this.name}/` : `${this.name}.json`;
29
+ }
30
+ getItemPath(id) {
31
+ return this.strategy === "sharded"
32
+ ? `${this.name}/${id}.json`
33
+ : `${this.name}.json`;
18
34
  }
19
35
  async create(item) {
20
36
  let finalItem = item;
37
+ // 1. Validation
38
+ if (this.validator) {
39
+ finalItem = await this.validator.validate(finalItem);
40
+ }
41
+ // 2. Middleware
21
42
  const context = {
22
43
  collection: this.name,
23
44
  operation: "create",
@@ -27,6 +48,22 @@ export class Collection {
27
48
  finalItem = await mw.beforeSave(finalItem, context);
28
49
  }
29
50
  }
51
+ if (this.strategy === "sharded") {
52
+ const itemPath = this.getItemPath(finalItem.id);
53
+ const sha = await this.storage.writeJson(itemPath, finalItem, `Create item ${finalItem.id} in ${this.name}`, this.shas.get(itemPath));
54
+ this.shas.set(itemPath, sha);
55
+ if (this.dataLoaded) {
56
+ const index = this.items.findIndex((i) => i.id === finalItem.id);
57
+ if (index > -1) {
58
+ this.items[index] = finalItem;
59
+ }
60
+ else {
61
+ this.items.push(finalItem);
62
+ }
63
+ this.indexer.add(finalItem);
64
+ }
65
+ return finalItem;
66
+ }
30
67
  let items = [];
31
68
  if (this.dataLoaded) {
32
69
  items = [...this.items];
@@ -92,9 +129,21 @@ export class Collection {
92
129
  }
93
130
  return this.items;
94
131
  }
95
- const response = await this.storage.readJson(this.path);
96
- this.lastSha = response.sha;
97
- items = response.data;
132
+ if (this.strategy === "sharded") {
133
+ const files = await this.storage.listDirectory(this.name);
134
+ items = await Promise.all(files
135
+ .filter((f) => f.type === "file" && f.path.endsWith(".json"))
136
+ .map(async (file) => {
137
+ const response = await this.storage.readJson(file.path);
138
+ this.shas.set(file.path, response.sha);
139
+ return response.data;
140
+ }));
141
+ }
142
+ else {
143
+ const response = await this.storage.readJson(this.path);
144
+ this.lastSha = response.sha;
145
+ items = response.data;
146
+ }
98
147
  items = await Promise.all(items.map(async (item) => {
99
148
  let currentItem = item;
100
149
  for (const mw of this.middleware) {
@@ -177,6 +226,31 @@ export class Collection {
177
226
  const results = this.indexer.query("id", id);
178
227
  return results && results.length > 0 ? results[0] : null;
179
228
  }
229
+ if (this.strategy === "sharded") {
230
+ try {
231
+ const itemPath = this.getItemPath(id);
232
+ const response = await this.storage.readJson(itemPath);
233
+ this.shas.set(itemPath, response.sha);
234
+ let finalItem = response.data;
235
+ const context = {
236
+ collection: this.name,
237
+ operation: "read",
238
+ };
239
+ for (const mw of this.middleware) {
240
+ if (mw.afterRead) {
241
+ finalItem = await mw.afterRead(finalItem, context);
242
+ }
243
+ }
244
+ // If data is not fully loaded, we don't update this.items yet to avoid partial consistency
245
+ // But if someone calls find() later it will load everything.
246
+ return finalItem;
247
+ }
248
+ catch (error) {
249
+ if (error.status === 404)
250
+ return null;
251
+ throw error;
252
+ }
253
+ }
180
254
  const items = await this.find();
181
255
  return items.find((item) => item.id === id) || null;
182
256
  }
@@ -189,6 +263,11 @@ export class Collection {
189
263
  const originalItem = items[index];
190
264
  items[index] = { ...items[index], ...updates };
191
265
  let finalItem = items[index];
266
+ // 1. Validation
267
+ if (this.validator) {
268
+ finalItem = await this.validator.validate(finalItem);
269
+ }
270
+ // 2. Middleware
192
271
  const context = {
193
272
  collection: this.name,
194
273
  operation: "update",
@@ -199,14 +278,29 @@ export class Collection {
199
278
  }
200
279
  }
201
280
  items[index] = finalItem;
202
- try {
203
- this.lastSha = await this.storage.writeJson(this.path, items, `Update item ${id} in ${this.name}`, this.lastSha);
281
+ if (this.strategy === "sharded") {
282
+ const itemPath = this.getItemPath(id);
283
+ try {
284
+ const sha = await this.storage.writeJson(itemPath, finalItem, `Update item ${id} in ${this.name}`, this.shas.get(itemPath));
285
+ this.shas.set(itemPath, sha);
286
+ }
287
+ catch (error) {
288
+ if (error instanceof ConcurrencyError) {
289
+ this.dataLoaded = false;
290
+ }
291
+ throw error;
292
+ }
204
293
  }
205
- catch (error) {
206
- if (error instanceof ConcurrencyError) {
207
- this.dataLoaded = false;
294
+ else {
295
+ try {
296
+ this.lastSha = await this.storage.writeJson(this.path, items, `Update item ${id} in ${this.name}`, this.lastSha);
297
+ }
298
+ catch (error) {
299
+ if (error instanceof ConcurrencyError) {
300
+ this.dataLoaded = false;
301
+ }
302
+ throw error;
208
303
  }
209
- throw error;
210
304
  }
211
305
  if (this.dataLoaded) {
212
306
  this.items = items;
@@ -222,14 +316,29 @@ export class Collection {
222
316
  }
223
317
  const itemToDelete = items[index];
224
318
  const filtered = items.filter((_, i) => i !== index);
225
- try {
226
- this.lastSha = await this.storage.writeJson(this.path, filtered, `Delete item ${id} from ${this.name}`, this.lastSha);
319
+ if (this.strategy === "sharded") {
320
+ const itemPath = this.getItemPath(id);
321
+ const sha = this.shas.get(itemPath);
322
+ if (!sha) {
323
+ // We need the SHA to delete. If we don't have it, we must fetch it.
324
+ const response = await this.storage.readJson(itemPath);
325
+ await this.storage.deleteFile(itemPath, `Delete item ${id} from ${this.name}`, response.sha);
326
+ }
327
+ else {
328
+ await this.storage.deleteFile(itemPath, `Delete item ${id} from ${this.name}`, sha);
329
+ }
330
+ this.shas.delete(itemPath);
227
331
  }
228
- catch (error) {
229
- if (error instanceof ConcurrencyError) {
230
- this.dataLoaded = false;
332
+ else {
333
+ try {
334
+ this.lastSha = await this.storage.writeJson(this.path, filtered, `Delete item ${id} from ${this.name}`, this.lastSha);
335
+ }
336
+ catch (error) {
337
+ if (error instanceof ConcurrencyError) {
338
+ this.dataLoaded = false;
339
+ }
340
+ throw error;
231
341
  }
232
- throw error;
233
342
  }
234
343
  if (this.dataLoaded) {
235
344
  this.items = filtered;
@@ -1,5 +1,6 @@
1
- import { GitHubDBConfig, IStorageProvider, Middleware, Schema } from "../core/types.js";
1
+ import { GitHubDBConfig, IStorageProvider, Middleware, Schema, StorageStrategy, Validator } from "../core/types.js";
2
2
  import { Collection } from "./collection.js";
3
+ import { Transaction } from "./transaction.js";
3
4
  export declare class GitHubDB {
4
5
  readonly config: GitHubDBConfig;
5
6
  readonly storage: IStorageProvider;
@@ -7,5 +8,8 @@ export declare class GitHubDB {
7
8
  connect(): Promise<boolean>;
8
9
  collection<T extends Schema>(name: string, options?: {
9
10
  middleware?: Middleware<T>[];
11
+ validator?: Validator<T>;
12
+ strategy?: StorageStrategy;
10
13
  }): Collection<T>;
14
+ transaction(fn: (tx: Transaction) => Promise<void>, message?: string): Promise<string>;
11
15
  }
@@ -1,5 +1,6 @@
1
1
  import { GitHubStorageProvider } from "../infrastructure/github-storage.js";
2
2
  import { Collection } from "./collection.js";
3
+ import { Transaction } from "./transaction.js";
3
4
  export class GitHubDB {
4
5
  config;
5
6
  storage;
@@ -20,6 +21,11 @@ export class GitHubDB {
20
21
  return this.storage.testConnection();
21
22
  }
22
23
  collection(name, options = {}) {
23
- return new Collection(name, this.storage, options.middleware);
24
+ return new Collection(name, this.storage, options);
25
+ }
26
+ async transaction(fn, message = "Transaction commit") {
27
+ const tx = new Transaction(this.storage);
28
+ await fn(tx);
29
+ return tx.commit(message);
24
30
  }
25
31
  }
@@ -0,0 +1,23 @@
1
+ import { IStorageProvider, Middleware, Schema, Validator } from "../core/types.js";
2
+ import { Collection } from "./collection.js";
3
+ /**
4
+ * Handles atomic batch operations across multiple collections.
5
+ */
6
+ export declare class Transaction {
7
+ private readonly baseStorage;
8
+ private txStorage;
9
+ constructor(baseStorage: IStorageProvider);
10
+ /**
11
+ * Returns a collection instance bound to this transaction.
12
+ * Operations on this collection will be buffered until the transaction is committed.
13
+ */
14
+ collection<T extends Schema>(name: string, options?: {
15
+ middleware?: Middleware<T>[];
16
+ validator?: Validator<T>;
17
+ }): Collection<T>;
18
+ /**
19
+ * Commits all buffered changes in the transaction to GitHub in a single commit.
20
+ * @internal
21
+ */
22
+ commit(message: string): Promise<string>;
23
+ }
@@ -0,0 +1,31 @@
1
+ import { TransactionStorageProvider } from "../infrastructure/transaction-storage.js";
2
+ import { Collection } from "./collection.js";
3
+ /**
4
+ * Handles atomic batch operations across multiple collections.
5
+ */
6
+ export class Transaction {
7
+ baseStorage;
8
+ txStorage;
9
+ constructor(baseStorage) {
10
+ this.baseStorage = baseStorage;
11
+ this.txStorage = new TransactionStorageProvider(baseStorage);
12
+ }
13
+ /**
14
+ * Returns a collection instance bound to this transaction.
15
+ * Operations on this collection will be buffered until the transaction is committed.
16
+ */
17
+ collection(name, options = {}) {
18
+ return new Collection(name, this.txStorage, options);
19
+ }
20
+ /**
21
+ * Commits all buffered changes in the transaction to GitHub in a single commit.
22
+ * @internal
23
+ */
24
+ async commit(message) {
25
+ const changes = this.txStorage.getChanges();
26
+ if (changes.length === 0) {
27
+ return "";
28
+ }
29
+ return this.txStorage.commit(changes, message);
30
+ }
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-as-db",
3
- "version": "1.1.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
+ }