gh-as-db 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tommy Hansen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # gh-as-db
2
+
3
+ [![NPM Version](https://img.shields.io/npm/v/gh-as-db.svg)](https://www.npmjs.com/package/gh-as-db)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Use a private GitHub repository as a database for your application. `gh-as-db` provides a familiar database-like interface (CRUD, filtering, sorting, pagination) while leveraging GitHub's infrastructure for versioned data storage.
7
+
8
+ ## Features
9
+
10
+ - 📂 **GitHub-Backed**: Your data lives in JSON files within a GitHub repository.
11
+ - 🔐 **Secure**: Designed for private repositories using Personal Access Tokens (PAT).
12
+ - 🚀 **Performance**: Built-in in-memory caching and auto-indexing for fast local queries.
13
+ - 🛡️ **Concurrency**: Optimistic locking using Git SHAs to prevent data loss.
14
+ - 🧩 **Middleware**: Extensible hooks for data validation or transformation.
15
+ - ⌨️ **CLI Tool**: Initialize and manage your "database" from the terminal.
16
+ - 📦 **TypeScript**: Fully typed for a great developer experience.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install gh-as-db
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ ```typescript
27
+ import { GitHubDB } from 'gh-as-db';
28
+
29
+ interface User {
30
+ id: string;
31
+ name: string;
32
+ email: string;
33
+ }
34
+
35
+ const db = new GitHubDB({
36
+ accessToken: process.env.GITHUB_TOKEN,
37
+ owner: 'your-username',
38
+ repo: 'my-data-repo',
39
+ });
40
+
41
+ // Access the 'users' collection
42
+ const users = db.collection<User>('users');
43
+
44
+ // Create a new user
45
+ await users.create({
46
+ id: '1',
47
+ name: 'John Doe',
48
+ email: 'john@example.com'
49
+ });
50
+
51
+ // Find a user by ID
52
+ const user = await users.findById('1');
53
+ console.log(user?.name); // 'John Doe'
54
+
55
+ // Query with filters
56
+ const results = await users.find({
57
+ filters: [
58
+ { field: 'name', operator: 'eq', value: 'John Doe' }
59
+ ]
60
+ });
61
+ ```
62
+
63
+ ## API Reference
64
+
65
+ ### `GitHubDB`
66
+
67
+ The entry point for linking to your GitHub repository.
68
+
69
+ ```typescript
70
+ const db = new GitHubDB({
71
+ accessToken: string; // GitHub PAT with 'repo' scope
72
+ owner: string; // Repository owner
73
+ repo: string; // Repository name
74
+ branch?: string; // Optional: branch to use (defaults to 'main')
75
+ });
76
+ ```
77
+
78
+ ### `Collection<T>`
79
+
80
+ Methods for interacting with your data collections (JSON files).
81
+
82
+ #### `create(item: T): Promise<T>`
83
+ Inserts a new item. If the file doesn't exist, it creates it.
84
+
85
+ #### `find(options?: QueryOptions<T> | ((item: T) => boolean)): Promise<T[]>`
86
+ Fetches items based on a query object or a predicate function.
87
+
88
+ #### `findById(id: string): Promise<T | null>`
89
+ Helper to find a single item by its `id` field.
90
+
91
+ #### `update(id: string, updates: Partial<T>): Promise<T>`
92
+ Updates an existing item. Throws if the item is not found.
93
+
94
+ #### `delete(id: string): Promise<void>`
95
+ Removes an item by its ID.
96
+
97
+ ### Querying
98
+
99
+ `gh-as-db` supports advanced querying including filtering, sorting, and pagination.
100
+
101
+ ```typescript
102
+ const items = await users.find({
103
+ filters: [
104
+ { field: 'age', operator: 'gte', value: 18 },
105
+ { field: 'status', operator: 'eq', value: 'active' }
106
+ ],
107
+ sort: [
108
+ { field: 'name', order: 'asc' }
109
+ ],
110
+ pagination: {
111
+ limit: 10,
112
+ offset: 0
113
+ }
114
+ });
115
+ ```
116
+
117
+ **Supported Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `in`.
118
+
119
+ ### Middleware
120
+
121
+ You can attach middleware to intercept operations.
122
+
123
+ ```typescript
124
+ const users = db.collection<User>('users', {
125
+ middleware: [{
126
+ beforeSave: async (item, context) => {
127
+ console.log(`Saving to ${context.collection}...`);
128
+ return { ...item, updatedAt: new Date().toISOString() };
129
+ }
130
+ }]
131
+ });
132
+ ```
133
+
134
+ ## CLI Usage
135
+
136
+ `gh-as-db` comes with a CLI tool to help you manage your repository.
137
+
138
+ ```bash
139
+ # Initialize a new repository
140
+ npx gh-as-db init
141
+
142
+ # List all collections
143
+ npx gh-as-db list
144
+
145
+ # Inspect a specific collection
146
+ npx gh-as-db inspect <collection-name>
147
+ ```
148
+
149
+ ## Why gh-as-db?
150
+
151
+ For small projects, side-projects, or internal tools, setting up a database server (PostgreSQL, MongoDB) is often overkill. `gh-as-db` gives you:
152
+ 1. **Zero Cost**: GitHub's free tier for private repos is enough for many use cases.
153
+ 2. **Versioned Data**: Every change is a commit. You can see history and revert easily.
154
+ 3. **Collaboration**: Use GitHub's own UI to edit data in a pinch.
155
+
156
+ ## Performance
157
+
158
+ - **Caching**: Data is fetched once and cached in memory. Subsequent reads are near-instant.
159
+ - **Indexing**: Automatic in-memory indexing on all fields makes querying fast even as data grows.
160
+ - **Optimistic Concurrency**: Uses Git SHAs to ensure that you don't overwrite changes made by another client.
161
+
162
+ ## License
163
+
164
+ MIT
@@ -0,0 +1,11 @@
1
+ import { Schema } from "./types.js";
2
+ export declare class Indexer<T extends Schema> {
3
+ private indexes;
4
+ build(items: T[], fields: (keyof T)[]): void;
5
+ query(field: keyof T, value: any): T[] | null;
6
+ add(item: T): void;
7
+ remove(item: T): void;
8
+ update(oldItem: T, newItem: T): void;
9
+ clear(): void;
10
+ hasIndex(field: keyof T): boolean;
11
+ }
@@ -0,0 +1,52 @@
1
+ export class Indexer {
2
+ indexes = new Map();
3
+ build(items, fields) {
4
+ this.indexes.clear();
5
+ for (const field of fields) {
6
+ const fieldIndex = new Map();
7
+ for (const item of items) {
8
+ const val = item[field];
9
+ if (!fieldIndex.has(val)) {
10
+ fieldIndex.set(val, new Set());
11
+ }
12
+ fieldIndex.get(val).add(item);
13
+ }
14
+ this.indexes.set(field, fieldIndex);
15
+ }
16
+ }
17
+ query(field, value) {
18
+ const fieldIndex = this.indexes.get(field);
19
+ if (!fieldIndex)
20
+ return null;
21
+ const matches = fieldIndex.get(value);
22
+ return matches ? Array.from(matches) : [];
23
+ }
24
+ add(item) {
25
+ for (const [field, fieldIndex] of this.indexes) {
26
+ const val = item[field];
27
+ if (!fieldIndex.has(val)) {
28
+ fieldIndex.set(val, new Set());
29
+ }
30
+ fieldIndex.get(val).add(item);
31
+ }
32
+ }
33
+ remove(item) {
34
+ for (const [field, fieldIndex] of this.indexes) {
35
+ const val = item[field];
36
+ const matches = fieldIndex.get(val);
37
+ if (matches) {
38
+ matches.delete(item);
39
+ }
40
+ }
41
+ }
42
+ update(oldItem, newItem) {
43
+ this.remove(oldItem);
44
+ this.add(newItem);
45
+ }
46
+ clear() {
47
+ this.indexes.clear();
48
+ }
49
+ hasIndex(field) {
50
+ return this.indexes.has(field);
51
+ }
52
+ }
@@ -0,0 +1,49 @@
1
+ export interface GitHubDBConfig {
2
+ accessToken: string;
3
+ owner: string;
4
+ repo: string;
5
+ }
6
+ export type Schema = Record<string, any>;
7
+ export type MiddlewareOperation = "create" | "update" | "read" | "delete";
8
+ export interface MiddlewareContext {
9
+ collection: string;
10
+ operation: MiddlewareOperation;
11
+ }
12
+ export interface Middleware<T extends Schema> {
13
+ beforeSave?: (item: T, context: MiddlewareContext) => Promise<T> | T;
14
+ afterRead?: (item: T, context: MiddlewareContext) => Promise<T> | T;
15
+ }
16
+ export type FilterOperator = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "contains" | "in";
17
+ export interface FilterPredicate<T> {
18
+ field: keyof T;
19
+ operator: FilterOperator;
20
+ value: any;
21
+ }
22
+ export type SortOrder = "asc" | "desc";
23
+ export interface SortOptions<T> {
24
+ field: keyof T;
25
+ order: SortOrder;
26
+ }
27
+ export interface PaginationOptions {
28
+ limit?: number;
29
+ offset?: number;
30
+ }
31
+ export interface QueryOptions<T> {
32
+ filters?: FilterPredicate<T>[];
33
+ sort?: SortOptions<T>[];
34
+ pagination?: PaginationOptions;
35
+ }
36
+ export declare class ConcurrencyError extends Error {
37
+ readonly path: string;
38
+ constructor(path: string);
39
+ }
40
+ export interface StorageResponse<T> {
41
+ data: T;
42
+ sha: string;
43
+ }
44
+ export interface IStorageProvider {
45
+ testConnection(): Promise<boolean>;
46
+ exists(path: string): Promise<boolean>;
47
+ readJson<T>(path: string): Promise<StorageResponse<T>>;
48
+ writeJson<T>(path: string, content: T, message: string, sha?: string): Promise<string>;
49
+ }
@@ -0,0 +1,8 @@
1
+ export class ConcurrencyError extends Error {
2
+ path;
3
+ constructor(path) {
4
+ super(`Concurrency conflict at ${path}. The remote data has changed.`);
5
+ this.path = path;
6
+ this.name = "ConcurrencyError";
7
+ }
8
+ }
@@ -0,0 +1 @@
1
+ export declare const version = "0.1.0";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export const version = "0.1.0";
@@ -0,0 +1,17 @@
1
+ export interface CacheEntry<T> {
2
+ data: T;
3
+ sha: string;
4
+ }
5
+ export interface ICacheProvider {
6
+ get<T>(key: string): CacheEntry<T> | null;
7
+ set<T>(key: string, value: CacheEntry<T>, ttl?: number): void;
8
+ delete(key: string): void;
9
+ clear(): void;
10
+ }
11
+ export declare class MemoryCacheProvider implements ICacheProvider {
12
+ private cache;
13
+ get<T>(key: string): CacheEntry<T> | null;
14
+ set<T>(key: string, value: CacheEntry<T>, ttl?: number): void;
15
+ delete(key: string): void;
16
+ clear(): void;
17
+ }
@@ -0,0 +1,23 @@
1
+ export class MemoryCacheProvider {
2
+ cache = new Map();
3
+ get(key) {
4
+ const entry = this.cache.get(key);
5
+ if (!entry)
6
+ return null;
7
+ if (entry.expiry !== null && Date.now() > entry.expiry) {
8
+ this.cache.delete(key);
9
+ return null;
10
+ }
11
+ return entry.data;
12
+ }
13
+ set(key, value, ttl) {
14
+ const expiry = ttl ? Date.now() + ttl : null;
15
+ this.cache.set(key, { data: value, expiry });
16
+ }
17
+ delete(key) {
18
+ this.cache.delete(key);
19
+ }
20
+ clear() {
21
+ this.cache.clear();
22
+ }
23
+ }
@@ -0,0 +1,12 @@
1
+ import { GitHubDBConfig, IStorageProvider, StorageResponse } from "../core/types.js";
2
+ import { ICacheProvider } from "./cache-provider.js";
3
+ export declare class GitHubStorageProvider implements IStorageProvider {
4
+ private config;
5
+ private octokit;
6
+ private cache;
7
+ constructor(config: GitHubDBConfig, cache?: ICacheProvider);
8
+ testConnection(): Promise<boolean>;
9
+ exists(path: string): Promise<boolean>;
10
+ readJson<T>(path: string): Promise<StorageResponse<T>>;
11
+ writeJson<T>(path: string, content: T, message: string, sha?: string): Promise<string>;
12
+ }
@@ -0,0 +1,106 @@
1
+ import { Octokit } from "@octokit/rest";
2
+ import { ConcurrencyError, } from "../core/types.js";
3
+ import { MemoryCacheProvider } from "./cache-provider.js";
4
+ export class GitHubStorageProvider {
5
+ config;
6
+ octokit;
7
+ cache;
8
+ constructor(config, cache) {
9
+ this.config = config;
10
+ this.octokit = new Octokit({
11
+ auth: config.accessToken,
12
+ });
13
+ this.cache = cache || new MemoryCacheProvider();
14
+ }
15
+ async testConnection() {
16
+ try {
17
+ await this.octokit.repos.get({
18
+ owner: this.config.owner,
19
+ repo: this.config.repo,
20
+ });
21
+ return true;
22
+ }
23
+ catch (error) {
24
+ return false;
25
+ }
26
+ }
27
+ async exists(path) {
28
+ try {
29
+ await this.octokit.repos.getContent({
30
+ owner: this.config.owner,
31
+ repo: this.config.repo,
32
+ path,
33
+ });
34
+ return true;
35
+ }
36
+ catch (error) {
37
+ if (error.status === 404) {
38
+ return false;
39
+ }
40
+ throw error;
41
+ }
42
+ }
43
+ async readJson(path) {
44
+ const cached = this.cache.get(path);
45
+ if (cached) {
46
+ return cached;
47
+ }
48
+ const response = await this.octokit.repos.getContent({
49
+ owner: this.config.owner,
50
+ repo: this.config.repo,
51
+ path,
52
+ });
53
+ if (Array.isArray(response.data)) {
54
+ throw new Error("Path is a directory, not a file");
55
+ }
56
+ if (!("content" in response.data) || !("sha" in response.data)) {
57
+ throw new Error("No content or SHA in response");
58
+ }
59
+ const content = Buffer.from(response.data.content, "base64").toString("utf-8");
60
+ const result = {
61
+ data: JSON.parse(content),
62
+ sha: response.data.sha,
63
+ };
64
+ this.cache.set(path, result);
65
+ return result;
66
+ }
67
+ async writeJson(path, content, message, sha) {
68
+ let internalSha = sha;
69
+ if (!internalSha) {
70
+ try {
71
+ const existing = await this.octokit.repos.getContent({
72
+ owner: this.config.owner,
73
+ repo: this.config.repo,
74
+ path,
75
+ });
76
+ if (!Array.isArray(existing.data) && "sha" in existing.data) {
77
+ internalSha = existing.data.sha;
78
+ }
79
+ }
80
+ catch (error) {
81
+ if (error.status !== 404) {
82
+ throw error;
83
+ }
84
+ }
85
+ }
86
+ try {
87
+ const response = await this.octokit.repos.createOrUpdateFileContents({
88
+ owner: this.config.owner,
89
+ repo: this.config.repo,
90
+ path,
91
+ message,
92
+ content: Buffer.from(JSON.stringify(content, null, 2)).toString("base64"),
93
+ sha: internalSha,
94
+ });
95
+ const newSha = response.data.content?.sha || "";
96
+ this.cache.delete(path);
97
+ return newSha;
98
+ }
99
+ catch (error) {
100
+ if (error.status === 409) {
101
+ throw new ConcurrencyError(path);
102
+ }
103
+ throw error;
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,3 @@
1
+ export declare function initCommand(): Promise<void>;
2
+ export declare function listCollectionsCommand(): Promise<void>;
3
+ export declare function inspectCollectionCommand(name: string): Promise<void>;
@@ -0,0 +1,108 @@
1
+ import enquirer from "enquirer";
2
+ import chalk from "chalk";
3
+ import { Octokit } from "@octokit/rest";
4
+ import { GitHubStorageProvider } from "../../infrastructure/github-storage.js";
5
+ export async function initCommand() {
6
+ console.log(chalk.yellow("\n--- Database Initialization ---\n"));
7
+ try {
8
+ const questions = [
9
+ {
10
+ type: "input",
11
+ name: "owner",
12
+ message: "GitHub Repository Owner:",
13
+ },
14
+ {
15
+ type: "input",
16
+ name: "repo",
17
+ message: "GitHub Repository Name:",
18
+ },
19
+ {
20
+ type: "password",
21
+ name: "accessToken",
22
+ message: "GitHub Personal Access Token:",
23
+ },
24
+ ];
25
+ const answers = await enquirer.prompt(questions);
26
+ const storage = new GitHubStorageProvider({
27
+ owner: answers.owner,
28
+ repo: answers.repo,
29
+ accessToken: answers.accessToken,
30
+ });
31
+ process.stdout.write(chalk.blue("Verifying connection... "));
32
+ const success = await storage.testConnection();
33
+ if (success) {
34
+ console.log(chalk.green("Success!"));
35
+ console.log(chalk.dim("\nYou can now use these credentials in your application configuration."));
36
+ }
37
+ else {
38
+ console.log(chalk.red("Failed."));
39
+ console.log(chalk.red("Please check your token permissions and repository details."));
40
+ }
41
+ }
42
+ catch (error) {
43
+ console.error(chalk.red("\nInitialization cancelled or failed."));
44
+ }
45
+ }
46
+ export async function listCollectionsCommand() {
47
+ console.log(chalk.yellow("\n--- Collections List ---\n"));
48
+ const owner = process.env.GH_DB_OWNER;
49
+ const repo = process.env.GH_DB_REPO;
50
+ const auth = process.env.GH_DB_TOKEN;
51
+ if (!owner || !repo || !auth) {
52
+ console.log(chalk.red("Error: Environment variables GH_DB_OWNER, GH_DB_REPO, and GH_DB_TOKEN must be set."));
53
+ return;
54
+ }
55
+ const octokit = new Octokit({ auth });
56
+ try {
57
+ const { data } = await octokit.repos.getContent({
58
+ owner,
59
+ repo,
60
+ path: "",
61
+ });
62
+ if (Array.isArray(data)) {
63
+ const collections = data
64
+ .filter((item) => item.name.endsWith(".json"))
65
+ .map((item) => item.name.replace(".json", ""));
66
+ if (collections.length === 0) {
67
+ console.log(chalk.dim("No collections found in the repository."));
68
+ }
69
+ else {
70
+ collections.forEach((name) => {
71
+ console.log(chalk.cyan(` • ${name}`));
72
+ });
73
+ }
74
+ }
75
+ }
76
+ catch (error) {
77
+ console.error(chalk.red(`Error fetching collections: ${error.message}`));
78
+ }
79
+ }
80
+ export async function inspectCollectionCommand(name) {
81
+ console.log(chalk.yellow(`\n--- Inspecting Collection: ${name} ---\n`));
82
+ const owner = process.env.GH_DB_OWNER;
83
+ const repo = process.env.GH_DB_REPO;
84
+ const auth = process.env.GH_DB_TOKEN;
85
+ if (!owner || !repo || !auth) {
86
+ console.log(chalk.red("Error: Environment variables GH_DB_OWNER, GH_DB_REPO, and GH_DB_TOKEN must be set."));
87
+ return;
88
+ }
89
+ const storage = new GitHubStorageProvider({
90
+ owner,
91
+ repo,
92
+ accessToken: auth,
93
+ });
94
+ try {
95
+ const path = `${name}.json`;
96
+ if (!(await storage.exists(path))) {
97
+ console.log(chalk.red(`Collection "${name}" does not exist.`));
98
+ return;
99
+ }
100
+ const response = await storage.readJson(path);
101
+ console.log(JSON.stringify(response.data, null, 2));
102
+ console.log(chalk.dim(`\nTotal records: ${response.data.length}`));
103
+ console.log(chalk.dim(`Current SHA: ${response.sha}`));
104
+ }
105
+ catch (error) {
106
+ console.error(chalk.red(`Error inspecting collection: ${error.message}`));
107
+ }
108
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import chalk from "chalk";
4
+ import boxen from "boxen";
5
+ import { initCommand, listCollectionsCommand, inspectCollectionCommand, } from "./commands.js";
6
+ const program = new Command();
7
+ console.log(boxen(chalk.bold.blue("gh-as-db CLI"), {
8
+ padding: 1,
9
+ margin: 1,
10
+ borderStyle: "double",
11
+ borderColor: "blue",
12
+ }));
13
+ program
14
+ .name("gh-as-db")
15
+ .description("CLI to manage your GitHub-based database")
16
+ .version("0.1.0");
17
+ program
18
+ .command("init")
19
+ .description("Initialize connection and verify repository")
20
+ .action(initCommand);
21
+ program
22
+ .command("list")
23
+ .alias("ls")
24
+ .description("List all collections in the database")
25
+ .action(listCollectionsCommand);
26
+ program
27
+ .command("inspect <collection>")
28
+ .description("Inspect the contents of a collection")
29
+ .action(inspectCollectionCommand);
30
+ program.parse();
@@ -0,0 +1,18 @@
1
+ import { IStorageProvider, Middleware, QueryOptions, Schema } from "../core/types.js";
2
+ export declare class Collection<T extends Schema> {
3
+ readonly name: string;
4
+ private readonly storage;
5
+ private readonly middleware;
6
+ private lastSha;
7
+ private indexer;
8
+ private items;
9
+ private dataLoaded;
10
+ constructor(name: string, storage: IStorageProvider, middleware?: Middleware<T>[]);
11
+ private get path();
12
+ create(item: T): Promise<T>;
13
+ find(queryOrPredicate?: ((item: T) => boolean) | QueryOptions<T>): Promise<T[]>;
14
+ private applyQueryOptions;
15
+ findById(id: string): Promise<T | null>;
16
+ update(id: string, updates: Partial<T>): Promise<T>;
17
+ delete(id: string): Promise<void>;
18
+ }
@@ -0,0 +1,236 @@
1
+ import { ConcurrencyError, } from "../core/types.js";
2
+ import { Indexer } from "../core/indexer.js";
3
+ export class Collection {
4
+ name;
5
+ storage;
6
+ middleware;
7
+ lastSha;
8
+ indexer = new Indexer();
9
+ items = [];
10
+ dataLoaded = false;
11
+ constructor(name, storage, middleware = []) {
12
+ this.name = name;
13
+ this.storage = storage;
14
+ this.middleware = middleware;
15
+ }
16
+ get path() {
17
+ return `${this.name}.json`;
18
+ }
19
+ async create(item) {
20
+ let finalItem = item;
21
+ const context = {
22
+ collection: this.name,
23
+ operation: "create",
24
+ };
25
+ for (const mw of this.middleware) {
26
+ if (mw.beforeSave) {
27
+ finalItem = await mw.beforeSave(finalItem, context);
28
+ }
29
+ }
30
+ let items = [];
31
+ if (this.dataLoaded) {
32
+ items = [...this.items];
33
+ }
34
+ else {
35
+ try {
36
+ if (await this.storage.exists(this.path)) {
37
+ const response = await this.storage.readJson(this.path);
38
+ items = response.data;
39
+ this.lastSha = response.sha;
40
+ }
41
+ }
42
+ catch (error) {
43
+ // If file doesn't exist, start with empty array
44
+ }
45
+ }
46
+ items.push(finalItem);
47
+ try {
48
+ this.lastSha = await this.storage.writeJson(this.path, items, `Add item to ${this.name}`, this.lastSha);
49
+ }
50
+ catch (error) {
51
+ if (error instanceof ConcurrencyError) {
52
+ this.dataLoaded = false;
53
+ }
54
+ throw error;
55
+ }
56
+ // Update in-memory state if already loaded
57
+ if (this.dataLoaded) {
58
+ this.items = items;
59
+ this.indexer.add(finalItem);
60
+ }
61
+ return finalItem;
62
+ }
63
+ async find(queryOrPredicate) {
64
+ if (!(await this.storage.exists(this.path))) {
65
+ return [];
66
+ }
67
+ let items;
68
+ const context = {
69
+ collection: this.name,
70
+ operation: "read",
71
+ };
72
+ // Use cached/indexed data if available
73
+ if (this.dataLoaded) {
74
+ // If it's an 'eq' filter we can potentially use index directly
75
+ if (typeof queryOrPredicate !== "function" &&
76
+ queryOrPredicate?.filters &&
77
+ queryOrPredicate.filters.length === 1 &&
78
+ queryOrPredicate.filters[0].operator === "eq" &&
79
+ this.indexer.hasIndex(queryOrPredicate.filters[0].field)) {
80
+ const filter = queryOrPredicate.filters[0];
81
+ const results = this.indexer.query(filter.field, filter.value);
82
+ if (results !== null) {
83
+ return results;
84
+ }
85
+ }
86
+ // Fallback to full scan of current in-memory items
87
+ if (typeof queryOrPredicate === "function") {
88
+ return this.items.filter(queryOrPredicate);
89
+ }
90
+ if (queryOrPredicate) {
91
+ return this.applyQueryOptions(this.items, queryOrPredicate);
92
+ }
93
+ return this.items;
94
+ }
95
+ const response = await this.storage.readJson(this.path);
96
+ this.lastSha = response.sha;
97
+ items = response.data;
98
+ items = await Promise.all(items.map(async (item) => {
99
+ let currentItem = item;
100
+ for (const mw of this.middleware) {
101
+ if (mw.afterRead) {
102
+ currentItem = await mw.afterRead(currentItem, context);
103
+ }
104
+ }
105
+ return currentItem;
106
+ }));
107
+ // Build index and store in-memory items
108
+ if (!this.dataLoaded) {
109
+ this.items = items;
110
+ this.indexer.build(items, items.length > 0 ? Object.keys(items[0]) : []);
111
+ this.dataLoaded = true;
112
+ }
113
+ if (typeof queryOrPredicate === "function") {
114
+ return items.filter(queryOrPredicate);
115
+ }
116
+ if (queryOrPredicate) {
117
+ return this.applyQueryOptions(items, queryOrPredicate);
118
+ }
119
+ return items;
120
+ }
121
+ applyQueryOptions(items, options) {
122
+ let result = [...items];
123
+ // Apply filters
124
+ if (options.filters) {
125
+ for (const filter of options.filters) {
126
+ result = result.filter((item) => {
127
+ const val = item[filter.field];
128
+ switch (filter.operator) {
129
+ case "eq":
130
+ return val === filter.value;
131
+ case "neq":
132
+ return val !== filter.value;
133
+ case "gt":
134
+ return val > filter.value;
135
+ case "gte":
136
+ return val >= filter.value;
137
+ case "lt":
138
+ return val < filter.value;
139
+ case "lte":
140
+ return val <= filter.value;
141
+ case "contains":
142
+ return (typeof val === "string" && val.includes(filter.value));
143
+ case "in":
144
+ return Array.isArray(filter.value) && filter.value.includes(val);
145
+ default:
146
+ return true;
147
+ }
148
+ });
149
+ }
150
+ }
151
+ // Apply sorting
152
+ if (options.sort) {
153
+ for (const sort of options.sort) {
154
+ result.sort((a, b) => {
155
+ const aVal = a[sort.field];
156
+ const bVal = b[sort.field];
157
+ if (aVal < bVal)
158
+ return sort.order === "asc" ? -1 : 1;
159
+ if (aVal > bVal)
160
+ return sort.order === "asc" ? 1 : -1;
161
+ return 0;
162
+ });
163
+ }
164
+ }
165
+ // Apply pagination
166
+ if (options.pagination) {
167
+ const { limit, offset = 0 } = options.pagination;
168
+ result = result.slice(offset, limit ? offset + limit : undefined);
169
+ }
170
+ return result;
171
+ }
172
+ async findById(id) {
173
+ if (this.dataLoaded && this.indexer.hasIndex("id")) {
174
+ const results = this.indexer.query("id", id);
175
+ return results && results.length > 0 ? results[0] : null;
176
+ }
177
+ const items = await this.find();
178
+ return items.find((item) => item.id === id) || null;
179
+ }
180
+ async update(id, updates) {
181
+ const items = await this.find();
182
+ const index = items.findIndex((item) => item.id === id);
183
+ if (index === -1) {
184
+ throw new Error(`Item with id ${id} not found in ${this.name}`);
185
+ }
186
+ const originalItem = items[index];
187
+ items[index] = { ...items[index], ...updates };
188
+ let finalItem = items[index];
189
+ const context = {
190
+ collection: this.name,
191
+ operation: "update",
192
+ };
193
+ for (const mw of this.middleware) {
194
+ if (mw.beforeSave) {
195
+ finalItem = await mw.beforeSave(finalItem, context);
196
+ }
197
+ }
198
+ items[index] = finalItem;
199
+ try {
200
+ this.lastSha = await this.storage.writeJson(this.path, items, `Update item ${id} in ${this.name}`, this.lastSha);
201
+ }
202
+ catch (error) {
203
+ if (error instanceof ConcurrencyError) {
204
+ this.dataLoaded = false;
205
+ }
206
+ throw error;
207
+ }
208
+ if (this.dataLoaded) {
209
+ this.items = items;
210
+ this.indexer.update(originalItem, finalItem);
211
+ }
212
+ return items[index];
213
+ }
214
+ async delete(id) {
215
+ const items = await this.find();
216
+ const index = items.findIndex((item) => item.id === id);
217
+ if (index === -1) {
218
+ return;
219
+ }
220
+ const itemToDelete = items[index];
221
+ const filtered = items.filter((_, i) => i !== index);
222
+ try {
223
+ this.lastSha = await this.storage.writeJson(this.path, filtered, `Delete item ${id} from ${this.name}`, this.lastSha);
224
+ }
225
+ catch (error) {
226
+ if (error instanceof ConcurrencyError) {
227
+ this.dataLoaded = false;
228
+ }
229
+ throw error;
230
+ }
231
+ if (this.dataLoaded) {
232
+ this.items = filtered;
233
+ this.indexer.remove(itemToDelete);
234
+ }
235
+ }
236
+ }
@@ -0,0 +1,11 @@
1
+ import { GitHubDBConfig, IStorageProvider, Middleware, Schema } from "../core/types.js";
2
+ import { Collection } from "./collection.js";
3
+ export declare class GitHubDB {
4
+ readonly config: GitHubDBConfig;
5
+ readonly storage: IStorageProvider;
6
+ constructor(config: GitHubDBConfig);
7
+ connect(): Promise<boolean>;
8
+ collection<T extends Schema>(name: string, options?: {
9
+ middleware?: Middleware<T>[];
10
+ }): Collection<T>;
11
+ }
@@ -0,0 +1,25 @@
1
+ import { GitHubStorageProvider } from "../infrastructure/github-storage.js";
2
+ import { Collection } from "./collection.js";
3
+ export class GitHubDB {
4
+ config;
5
+ storage;
6
+ constructor(config) {
7
+ this.config = config;
8
+ if (!config.accessToken) {
9
+ throw new Error("accessToken is required");
10
+ }
11
+ if (!config.owner) {
12
+ throw new Error("owner is required");
13
+ }
14
+ if (!config.repo) {
15
+ throw new Error("repo is required");
16
+ }
17
+ this.storage = new GitHubStorageProvider(config);
18
+ }
19
+ async connect() {
20
+ return this.storage.testConnection();
21
+ }
22
+ collection(name, options = {}) {
23
+ return new Collection(name, this.storage, options.middleware);
24
+ }
25
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "gh-as-db",
3
+ "version": "1.0.0",
4
+ "description": "Use a private GitHub repository as a database for your application.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "gh-as-db": "./dist/ui/cli/index.js"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "test": "vitest",
24
+ "test:run": "vitest run",
25
+ "build": "tsc",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "keywords": [
29
+ "github",
30
+ "database",
31
+ "storage",
32
+ "orm",
33
+ "json",
34
+ "collection"
35
+ ],
36
+ "author": "Tommy Hansen",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/almyk/gh-as-db.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/almyk/gh-as-db/issues"
44
+ },
45
+ "homepage": "https://github.com/almyk/gh-as-db#readme",
46
+ "engines": {
47
+ "node": ">=18.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^25.0.8",
51
+ "@vitest/coverage-v8": "^4.0.17",
52
+ "ts-node": "^10.9.2",
53
+ "typescript": "^5.9.3",
54
+ "vitest": "^4.0.17"
55
+ },
56
+ "dependencies": {
57
+ "@octokit/rest": "^22.0.1",
58
+ "boxen": "^8.0.1",
59
+ "chalk": "^5.4.1",
60
+ "commander": "^13.1.0",
61
+ "enquirer": "^2.4.1"
62
+ }
63
+ }