@secret-momo/github-db 0.0.1 → 0.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/KNOWN-ISSUES.md +12 -0
- package/LICENSE +21 -0
- package/README.md +217 -2
- package/docs/architecture.md +116 -0
- package/docs/caller-api-design.md +12 -0
- package/docs/record-api-design.md +53 -0
- package/docs/requirements.md +83 -0
- package/docs/testing.md +41 -0
- package/lib/index.d.ts +212 -1
- package/lib/index.js +6 -1
- package/package.json +18 -7
package/KNOWN-ISSUES.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# 已知问题
|
|
2
|
+
|
|
3
|
+
本文档仅记录尚无法由本项目消除的已知缺陷或工具局限。产品支持范围、接口契约和设计取舍分别以 [需求文档](docs/requirements.md) 和 [架构文档](docs/architecture.md) 为准,不在此重复登记。
|
|
4
|
+
|
|
5
|
+
代码审查与修改前应同时阅读需求、架构和本文档。已知局限不豁免产品契约或测试要求;新的失败证据仍需评估,尚待开发的功能不能因此视为“不修复项”。
|
|
6
|
+
|
|
7
|
+
## 1. Bun 覆盖率插桩可能将未执行的分支记录为命中
|
|
8
|
+
|
|
9
|
+
- **位置**:`bunfig.toml`;`scripts/check-coverage.mjs`;`docs/testing.md`。
|
|
10
|
+
- **问题**:Bun LCOV 曾将未执行的防御分支记录为命中;原报告全行命中也未发现深层记录解析栈溢出,因此行/函数覆盖率报告不能证明全部分支实际执行过。
|
|
11
|
+
- **处理**:不在本仓库改造第三方插桩实现。已删除识别出的死分支,并以关键契约的行为测试、Node 原生 HTTP 测试及变异验证补强;深层记录解析已增加明确的深度边界和回归断言。
|
|
12
|
+
- **约束**:所有产品运行时源码仍须进入 LCOV,逐文件行/函数覆盖率至少 90%,运行时零行块直接失败;纯类型声明没有运行时计数。这项工具局限不能作为跳过测试、降低门槛或宣称所有分支已验证的理由。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 github-db contributors
|
|
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
CHANGED
|
@@ -1,3 +1,218 @@
|
|
|
1
|
-
#
|
|
1
|
+
# GitHub DB
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
TypeScript ESM SDK,将已有 GitHub 仓库存储为低频、小规模的 JSONL 表。支持 Node.js 24+ 与 Bun,运行和开发仅支持 macOS/Linux。
|
|
4
|
+
|
|
5
|
+
每张表只有两个文件,整张表的所有记录存放在同一个 data.jsonl 中:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
workbench/
|
|
9
|
+
└── todolist/
|
|
10
|
+
├── meta.json
|
|
11
|
+
└── data.jsonl
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
提供两个入口:`getRecordTable<T>()` 用于类型化 CRUD;`getTable()` 用于底层 JSONL 字符串读写。两者操作相同的表文件。仓库和目标分支必须人工保证已有至少一次提交,SDK 不初始化空仓库或创建分支。
|
|
15
|
+
|
|
16
|
+
## 安装与配置
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bun add @secret-momo/github-db
|
|
20
|
+
# 或 npm install @secret-momo/github-db
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { GitHubDBClient } from '@secret-momo/github-db';
|
|
25
|
+
|
|
26
|
+
const client = new GitHubDBClient({
|
|
27
|
+
token: process.env.GITHUB_TOKEN!,
|
|
28
|
+
owner: 'your-owner',
|
|
29
|
+
repo: 'your-repo',
|
|
30
|
+
// branch: 'main', // 省略时发现并缓存默认分支
|
|
31
|
+
// timeout: 30_000, // 每个请求的超时毫秒数
|
|
32
|
+
});
|
|
33
|
+
const db = client.getDB('workbench');
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
获取 client、DB、table 句柄只创建本地对象,不发送请求或产生提交。
|
|
37
|
+
|
|
38
|
+
获取指定数据库下的所有表名:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const tableNames = await client.getDB('workbench').listTables(); // string[]
|
|
42
|
+
// 也可传入 { signal } 取消请求。
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`listTables(options?)` 在同一个 Git 快照中列出直接子目录内同时包含普通文件 `meta.json` 和 `data.jsonl` 的表,按表名排序。数据库不存在或没有表时返回 `[]`;数据库路径为文件时返回 INVALID_METADATA。忽略其他文件、非表目录及非法表名,不下载或验证表内容,也不产生提交。
|
|
46
|
+
|
|
47
|
+
自建代理沿用 GitHub 协议及路径,配置 endpoint 和额外鉴权 header 即可:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const proxied = new GitHubDBClient({
|
|
51
|
+
token: process.env.GITHUB_TOKEN!,
|
|
52
|
+
owner: 'your-owner',
|
|
53
|
+
repo: 'your-repo',
|
|
54
|
+
endpoint: process.env.GITHUB_RELAY_ENDPOINT!,
|
|
55
|
+
headers: { 'x-cfgr-token': process.env.GITHUB_RELAY_TOKEN! },
|
|
56
|
+
apiVersion: '2026-03-10',
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
URL 保留 endpoint 路径前缀,例如 `/api/github/repos/your-owner/your-repo/git/...`。所有请求和 raw blob 下载均经过该地址;GitHub Bearer token 与代理 header 同时发送。自定义 header 不能覆盖 Authorization、Accept、Content-Type 或 X-GitHub-Api-Version。endpoint 只允许无凭据、查询或 fragment 的 HTTP(S) URL,不跟随重定向。
|
|
61
|
+
|
|
62
|
+
还可注入 `fetch(url, init)`。timeout 在 1~2,147,483,647 毫秒之间;apiVersion 为日期字符串,默认 `2026-03-10`。
|
|
63
|
+
|
|
64
|
+
## 类型化 CRUD
|
|
65
|
+
|
|
66
|
+
调用方指定完整记录类型,继承 `RecordBase` 获得 id、createdAt、updatedAt 三个系统字段:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import type { RecordBase } from '@secret-momo/github-db';
|
|
70
|
+
|
|
71
|
+
interface Todo extends RecordBase {
|
|
72
|
+
title: string;
|
|
73
|
+
status: 'pending' | 'done';
|
|
74
|
+
priority?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const todos = db.getRecordTable<Todo>('todolist');
|
|
78
|
+
|
|
79
|
+
// 自动生成 UUID id,两个时间字段自动使用数字毫秒时间戳。
|
|
80
|
+
const created = await todos.insert({
|
|
81
|
+
title: '学习 TypeScript',
|
|
82
|
+
status: 'pending',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// 也可以提供 id;同一张表内重复时报 DUPLICATE_ID。
|
|
86
|
+
await todos.insert({
|
|
87
|
+
id: 'task-2',
|
|
88
|
+
title: '完成工作',
|
|
89
|
+
status: 'pending',
|
|
90
|
+
priority: 1,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const first = await todos.find({ status: 'pending', priority: 1 }); // Todo | null
|
|
94
|
+
const found = await todos.findById(created.id); // Todo | null
|
|
95
|
+
const pending = await todos.findAll({ status: 'pending' }); // Todo[]
|
|
96
|
+
const all = await todos.findAll(); // Todo[]
|
|
97
|
+
|
|
98
|
+
const updated = await todos.updateById(created.id, { status: 'done' }); // Todo
|
|
99
|
+
const deleted = await todos.deleteById('task-2'); // boolean
|
|
100
|
+
|
|
101
|
+
// 下列调用会被 TypeScript 拒绝:
|
|
102
|
+
// todos.insert({ title: 'x', status: 'invalid' });
|
|
103
|
+
// todos.find({ unknownField: true });
|
|
104
|
+
// todos.updateById(created.id, { id: 'new-id' });
|
|
105
|
+
// db.getRecordTable('missing-type');
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
| 接口 | 行为 |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `insert(record, options?)` | 首次自动建表,追加一条记录,返回完整记录。id 可省略,时间字段不得传入。 |
|
|
111
|
+
| `find(where?, options?)` | 返回首个匹配记录,没有匹配时返回 null。 |
|
|
112
|
+
| `findById(id, options?)` | 使用 `find({ id })` 实现,返回记录或 null。 |
|
|
113
|
+
| `findAll(where?, options?)` | 返回所有匹配记录,不传条件时列出全表。 |
|
|
114
|
+
| `updateById(id, patch, options?)` | 顶层合并业务字段、更新 updatedAt;不存在时报 NOT_FOUND。 |
|
|
115
|
+
| `deleteById(id, options?)` | 删除成功返回 true,不存在返回 false,不创建表、不提交。 |
|
|
116
|
+
|
|
117
|
+
查询仅支持顶层字段等值,多字段按 AND 组合;对象按结构比较且忽略键顺序,数组按元素顺序比较,null 与不存在的字段不同。结果遵循文件中的记录顺序,find 基于 findAll 实现。不支持比较操作符、SQL、排序、分页或 JOIN。
|
|
118
|
+
|
|
119
|
+
更新保留 id 与 createdAt,不允许修改任何系统字段;嵌套对象整体替换,不能传空 patch。时间取自系统时钟,同一毫秒中的操作可以有相同 updatedAt。删除记录不删除表,删除最后一条记录后 data.jsonl 为空。
|
|
120
|
+
|
|
121
|
+
读不到表时,find/findById 返回 null,findAll 返回 []。CRUD 会检查每条记录的系统字段及全表 id 唯一性;已有坏数据报错,不自动修复。不同表可以使用相同 id。
|
|
122
|
+
|
|
123
|
+
TS 泛型提供编译期业务字段检查,不是运行时 schema。运行时检查普通 JSON 对象、主键、时间字段和重复记录;不会推断 title 是否必填或 status 的业务枚举。只允许普通对象、密集数组、字符串、有限数字、布尔值、null;拒绝 undefined、函数、symbol、BigInt、循环引用、Date/Map/Set、访问器、隐藏属性及非法 Unicode。高精度数值建议用字符串;读取不能精确往返为 JavaScript number 的数字字面量时报 INVALID_RECORD。
|
|
124
|
+
|
|
125
|
+
记录、更新 patch 和查询条件最多嵌套 512 层对象/数组容器,根对象算第一层。超限输入在请求前报 INVALID_INPUT/TOO_DEEP;已有超限记录报 INVALID_RECORD/TOO_DEEP,并提供 lineNumber。底层字符串 API 不限制 JSON 嵌套深度,但与 CRUD 混用时须遵守记录层限制。
|
|
126
|
+
|
|
127
|
+
DB、表名和记录 id 都按原始字符串精确匹配,不自动做 Unicode 规范化;NFC/NFD 同形字符串可能对应不同键。如需统一,同一数据源的所有调用入口应一致使用 `normalize('NFC')`。
|
|
128
|
+
|
|
129
|
+
CRUD 写操作自动处理并发检查。每次在同一个队列和 Git 快照内读取全表、执行修改并发布,不需要调用方手工处理 JSONL 或版本令牌。查询下载完整表并在内存中过滤,写操作替换完整文件,因此适用于低频、小规模数据。
|
|
130
|
+
|
|
131
|
+
## 底层字符串 API
|
|
132
|
+
|
|
133
|
+
底层只要求每行是 JSON 对象,不要求业务 id 或记录时间字段,也不解析为业务类型:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const table = db.getTable('raw-events');
|
|
137
|
+
const created = await table.write('{"event":"开始"}\n', {
|
|
138
|
+
expectedVersion: null,
|
|
139
|
+
});
|
|
140
|
+
const read = await table.read();
|
|
141
|
+
console.log(read.content); // 原始 UTF-8 JSONL 文本
|
|
142
|
+
console.log(read.meta.recordCount);
|
|
143
|
+
|
|
144
|
+
await table.write('{"event":"完成"}\n', { expectedVersion: read.version });
|
|
145
|
+
const metadata = await table.getMeta(); // { meta, commitSha },只下载 meta.json
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`read()` 返回 `{ content, meta, version, commitSha }`,`write()` 返回 `{ meta, version, commitSha }`。read/getMeta 在表不存在时报 NOT_FOUND。write 首次自动创建、后续整体替换;写入 `''` 清空数据。
|
|
149
|
+
|
|
150
|
+
`expectedVersion` 省略表示替换最新表,null 要求表不存在,不透明令牌要求版本匹配。每次写入递增表 revision,即使内容相同也使旧令牌过期;令牌绑定 endpoint、仓库、有效分支、DB、表、revision 与实际 data SHA,其他表更新不影响当前令牌。
|
|
151
|
+
|
|
152
|
+
原始文本接受 CRLF 和缺末尾换行,保留数值字面量与字段顺序;拒绝 BOM、内部空行、非法 JSON、非对象行及非法 UTF-8。空字符串表示零记录。单次写入最多 10,000,000 UTF-8 字节,超限报 TABLE_TOO_LARGE;有效超限旧文件可读取并缩小。大小文件统一通过 raw Git Blobs 读取,不使用 Contents 内联内容或 download_url。
|
|
153
|
+
|
|
154
|
+
底层 write 不验证业务主键。若与 CRUD 混用,调用方必须写入符合完整记录约束的数据,否则 CRUD 会报错。
|
|
155
|
+
|
|
156
|
+
## 元信息与原子提交
|
|
157
|
+
|
|
158
|
+
meta.json 的 version 固定为 1;createdAt、updatedAt 为数字 Unix 毫秒时间戳。recordCount、byteLength、revision 和 dataSha 都属于整张表:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"version": 1,
|
|
163
|
+
"createdAt": 1791244800000,
|
|
164
|
+
"updatedAt": 1791244800000,
|
|
165
|
+
"recordCount": 0,
|
|
166
|
+
"byteLength": 0,
|
|
167
|
+
"revision": 1,
|
|
168
|
+
"dataSha": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
meta 与 data 在同一次 Git 提交中更新,读取固定在同一个 commit。定向读写验证 SHA、JSONL 与统计;getMeta 只检查 meta 的结构。元信息未知扩展字段保留,无法直接用 JS number 表示的未知数字通过 JSON raw 值保存其原始字面量。公开 TableMeta 类型不提供宽索引签名。
|
|
173
|
+
|
|
174
|
+
meta(包括扩展字段)同样最多 512 层对象/数组容器,超限报 INVALID_METADATA/TOO_DEEP。
|
|
175
|
+
|
|
176
|
+
同一 client 的所有写操作共享队列。其他 client 或外部提交使发布不再快进时,返回 CONFLICT,不强制覆盖、不自动重试;不承诺外部 force reset 下的严格 CAS。普通替换和删除保留 Git 历史,SDK 不负责历史清除。
|
|
177
|
+
|
|
178
|
+
## 取消、提交信息与错误
|
|
179
|
+
|
|
180
|
+
底层和 CRUD 都支持 AbortSignal:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
await todos.insert(
|
|
184
|
+
{ title: '新任务', status: 'pending' },
|
|
185
|
+
{
|
|
186
|
+
signal: AbortSignal.timeout(30_000),
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
await todos.findAll({ status: 'pending' }, { signal: controller.signal });
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
提交信息由 SDK 自动生成,不接受调用方指定,固定为 `github-db: <operation> "<db>/<table>"`。操作为 `write`、`insert`、`update` 或 `delete`,表路径使用 JSON 字符串转义。例如 `github-db: insert "app/todos"`。首次建表沿用调用操作;删除不存在的记录不产生提交。消息不包含记录内容、ID、时间戳或 SHA。
|
|
193
|
+
|
|
194
|
+
错误为 GitHubDBError,提供 code 与 details,包括 reason、path、table、field、lineNumber 和可用的 HTTP 诊断字段。dispatched 仅表示调用了 fetch,不证明服务器已收到请求。
|
|
195
|
+
|
|
196
|
+
写入已创建 Git 对象、但在发布前取消时,返回 ABORTED,dispatched 为 true,reason 为 BEFORE_PUBLICATION,并提供已创建的 commitSha;此时没有发送发布 PATCH,表内容未更新。
|
|
197
|
+
|
|
198
|
+
- INVALID_INPUT / INVALID_RECORD / DUPLICATE_ID:非法输入、存储记录违反约束或重复主键。
|
|
199
|
+
- INVALID_METADATA / INVALID_JSONL / INVALID_UTF8:表完整性或文件格式错误。
|
|
200
|
+
- NOT_FOUND / CONFLICT:目标不存在、版本不匹配或发布被拒绝;CONFLICT 的 reason 区分 VERSION_MISMATCH 与 PUBLISH_REJECTED。
|
|
201
|
+
- HTTP_ERROR / NETWORK_ERROR / ABORTED / TIMEOUT:传输及取消错误;3xx 保留 REDIRECT 原因,不跟随。
|
|
202
|
+
- WRITE_RESULT_UNKNOWN:发布请求可能已成功,携带尝试 commitSha;先核实仓库状态再决定是否重试,自动 id 插入也不能盲目重试。
|
|
203
|
+
|
|
204
|
+
上游限流与分支竞争需由调用方处理;不要依赖报文正文判断错误或持续吞吐。
|
|
205
|
+
|
|
206
|
+
## 开发与测试
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
bun install
|
|
210
|
+
bun run check
|
|
211
|
+
bun run test:live # .env 中配置 GITHUB_TOKEN、GITHUB_OWNER、GITHUB_REPO、GITHUB_DB_NAME
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
完整 check 包含类型检查、lint、格式、Bun 单元及原生 HTTP 测试、逐文件行/函数至少 90% 覆盖率、构建、Node 本地 HTTP 和 npm 包的 NodeNext 类型消费测试。test:live 显式写入配置的真实测试仓库,覆盖单表、多张独立表、CRUD、空文件及大于 1 MB 的文件,并清理本次生成的表。
|
|
215
|
+
|
|
216
|
+
构建只输出压缩的 `lib/index.js` 和合并的 `lib/index.d.ts`,无 map。源码内部引用省略扩展名。npm publish 自动执行完整检查,检查包含构建;npm pack 也自动构建,无需人工额外运行 build。
|
|
217
|
+
|
|
218
|
+
需求与设计见 [requirements](docs/requirements.md)、[architecture](docs/architecture.md)、[record API](docs/record-api-design.md)。验证说明见 [testing](docs/testing.md),已知工具局限见 [KNOWN-ISSUES](KNOWN-ISSUES.md)。采用 MIT 许可证。
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# GitHub 数据库 SDK 架构
|
|
2
|
+
|
|
3
|
+
当前设计:单表存储、底层字符串 API 与类型化记录 API。规范见 [requirements](requirements.md),记录接口见 [record-api-design](record-api-design.md)。
|
|
4
|
+
|
|
5
|
+
## 1. 分层与源码
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
flowchart TD
|
|
9
|
+
Client[GitHubDBClient] --> DB[GitHubDatabase]
|
|
10
|
+
DB --> Raw[GitHubTable:JSONL 字符串]
|
|
11
|
+
DB --> Records[GitHubRecordTable:类型化 CRUD]
|
|
12
|
+
Raw --> Storage[TableStorage:完整性与快照内提交]
|
|
13
|
+
Records --> Storage
|
|
14
|
+
Records --> Codec[记录 JSON 校验 / 主键 / 条件匹配]
|
|
15
|
+
Storage --> Repo[GitRepository:快照、队列、原子发布]
|
|
16
|
+
Repo --> Tree[GitTreeReader:路径与树缓存]
|
|
17
|
+
Repo --> HTTP[GitHubTransport:HTTP、鉴权、取消]
|
|
18
|
+
Tree --> HTTP
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
src/
|
|
23
|
+
├── index.ts # 公开 SDK 导出
|
|
24
|
+
├── client.ts # Client 与 DB 入口
|
|
25
|
+
├── database/
|
|
26
|
+
│ ├── database.ts # 底层/高层表工厂
|
|
27
|
+
│ ├── table.ts # 字符串 API 与不透明版本
|
|
28
|
+
│ └── options.ts # 操作选项/取消校验
|
|
29
|
+
├── records/
|
|
30
|
+
│ ├── table.ts # CRUD、条件匹配、读改写编排
|
|
31
|
+
│ ├── codec.ts # JSONL 记录与系统字段/重复校验
|
|
32
|
+
│ ├── json.ts # JSON 可存储性与输入复制
|
|
33
|
+
│ └── types.ts # 记录、插入、更新、条件类型
|
|
34
|
+
├── storage/
|
|
35
|
+
│ ├── table.ts # meta/data 一致性与快照内写入
|
|
36
|
+
│ ├── meta.ts # 元信息数值保真 codec
|
|
37
|
+
│ └── jsonl.ts # JSONL 与 UTF-8 容量
|
|
38
|
+
├── github/
|
|
39
|
+
│ ├── repository.ts # 分支、快照、串行队列与 Git 提交
|
|
40
|
+
│ ├── tree.ts # 树解析与路径定位
|
|
41
|
+
│ ├── transport.ts # 配置与 HTTP 请求/响应
|
|
42
|
+
│ ├── response.ts # Git 响应字段校验
|
|
43
|
+
│ └── types.ts # 内部 Git 对象类型
|
|
44
|
+
├── types.ts # 底层公开类型
|
|
45
|
+
├── json.ts # 深度边界与精确十进制分解
|
|
46
|
+
├── errors.ts # 结构化错误
|
|
47
|
+
└── validation.ts # 通用输入校验
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
底层 getTable 字符串 API 与高层 getRecordTable<T> 记录 API 保持独立入口,不合并为带可选业务类型的句柄。底层只要求 JSONL 对象,高层要求显式类型、主键和系统时间;独立入口明确两层契约并便于类型校验。两种表句柄操作相同文件、共享 repository 队列和 TableStorage,不复制 meta 校验或 Git 提交流程。协议层不依赖业务表格式。获取句柄无网络请求,每张表固定 `<db>/<table>/meta.json` 与 `data.jsonl`。
|
|
51
|
+
|
|
52
|
+
## 2. 固定快照与表状态
|
|
53
|
+
|
|
54
|
+
分支发现由 client 共享,一个等待者取消不取消其他等待者;失败清除发现 Promise,允许后续再尝试。GET ref 固定 commitSha,GET commit 获取 treeSha。逐层非递归 GET trees,快照内按 SHA 缓存 Promise,拒绝截断树、非目录父路径和非普通文件。
|
|
55
|
+
|
|
56
|
+
快照固定保留 GET ref、GET commit 两个 Git Data 请求,不改为单次 branches/commits 查询;现有流程按不可变 commit 读取树,覆盖代理路由及含斜杠分支,无需为低频场景节省一次 GET 引入额外端点。
|
|
57
|
+
|
|
58
|
+
缓存仅包括默认分支缓存、首次发现请求合并和单次快照内 tree Promise 去重。跨操作读取重新获取快照和文件内容,不增加实例级 blob LRU、跨操作 SHA 缓存或 ETag/304 分支。若需求转向高频轮询,应先评估请求预算和存储定位,再以负载证据决定缓存策略。
|
|
59
|
+
|
|
60
|
+
TableStorage 在同一个快照定位 meta/data,解析 meta 并核对存在性。表目录已有内容而 meta 缺失、meta 存在但 data 缺失,均视为损坏。读取 raw blob 严格 UTF-8 解码,检查 dataSha、JSONL 与统计;不能以空表替代坏状态。getMeta 只加载元信息。
|
|
61
|
+
|
|
62
|
+
meta 的已知数值字段验证精确数学值与安全整数,拒绝负零;未知字段通过 JSON source/raw API 保留无法直接表示的原始数字。此能力依赖 Node.js 24+/Bun。
|
|
63
|
+
|
|
64
|
+
记录与 meta 的 reviver 解析前扫描 JSON 文本深度,忽略字符串内的括号和转义,最多 512 层容器。记录输入遍历采用同一上限;共享十进制分解用于 meta 的整数检查和记录数字保真检查,保留各层不同的错误语义。
|
|
65
|
+
|
|
66
|
+
## 3. 底层写入与版本
|
|
67
|
+
|
|
68
|
+
JSONL 读取和校验在内存持有完整字符串,CRUD 下载全表后查询、修改并整表替换;该模型与 Git blob 和原子提交一致,适用于低频、小规模数据。容量边界按需求文档执行。
|
|
69
|
+
|
|
70
|
+
底层 write 先校验输入 JSONL、容量、取消与令牌格式,在 repository 队列内读取最新表快照,比较令牌后校验旧数据完整性。版本由 SHA-256 生成,输入包含连接作用域、DB、表、revision、实际 data SHA。
|
|
71
|
+
|
|
72
|
+
输入校验返回的 recordCount/byteLength 直接传入 TableStorage.write,避免对同一新内容重复扫描。高层对修改后的 JSONL 计算一次统计后使用同一持久化入口。
|
|
73
|
+
|
|
74
|
+
底层 write 和高层 CRUD 在内部传递固定操作类型,TableStorage.write 统一生成 `github-db: <operation> "<db>/<table>"` 提交信息,使用 JSON.stringify 转义表路径。GitRepository 只接收生成后的消息,不推断业务操作。
|
|
75
|
+
|
|
76
|
+
TableStorage.write 使用已有快照及已检查状态创建 data blob,得到 SHA;递增表 revision,设置统计和毫秒时间,创建 meta blob。GitRepository 创建基于原 tree 的增量 tree、以固定 commit 为 parent 的新 commit,最后非强制 PATCH ref。一张表的两个文件同时发布,无关文件保持原样。
|
|
77
|
+
|
|
78
|
+
令牌保护调用方 read-modify-write,CRUD 隐藏该细节但仍使用同一快照和发布保护。对外 force reset 不提供严格 CAS。
|
|
79
|
+
|
|
80
|
+
## 4. 记录与类型
|
|
81
|
+
|
|
82
|
+
RecordBase 定义 id/createdAt/updatedAt。工厂要求完整泛型,并禁止省略类型及窄系统字段;RecordInsert 排除时间字段、允许可选 id;RecordUpdate 排除全部系统字段,使用 optional never 也阻止带系统字段的变量作为 patch;RecordWhere 关联字段名及其类型。
|
|
83
|
+
|
|
84
|
+
运行时遍历输入检查 JSON 数据模型,先拒绝访问器、隐藏/symbol 属性、稀疏数组、循环引用、非普通对象及非法值,再 stringify/parse 复制输入,避免排队期间外部修改影响操作。不会从 TS 泛型生成业务 schema,不引入额外 schema 框架;泛型在运行时擦除,业务字段类型由 TS 编译期校验。meta、主键、系统时间与 JSON 数据模型必须完成运行时验证,codec 可在验证后使用局部类型断言,但不能据此声称已验证业务字段枚举。
|
|
85
|
+
|
|
86
|
+
读取记录校验 id、系统时间与重复 id;通过 JSON reviver source 比较原始数字与其 JS 序列化值的十进制数值,拒绝舍入或溢出,但允许 1.0/1e0 等等价表达。每条记录保存解析对象和原始行,修改仅序列化被修改的行,未触及行保留原文本。
|
|
87
|
+
|
|
88
|
+
## 5. CRUD 读改写
|
|
89
|
+
|
|
90
|
+
查询固定快照并验证全表记录,使用顶层字段 AND 等值匹配;通过 Object.hasOwn 区分缺字段,使用结构比较处理 JSON 对象/数组。find 基于 findAll,findById 基于 find。
|
|
91
|
+
|
|
92
|
+
insert/update/delete 都在 repository 队列内固定快照、验证全表、计算修改,再用同一快照交给 TableStorage.write。insert 检查实际 id 集合,即使 UUID 自动生成也不能绕过重复检查。update 不允许系统字段变更,delete 不匹配时无写入。完整操作共享队列,防止实例内并发读取旧数据后互相覆盖。
|
|
93
|
+
|
|
94
|
+
跨 client 操作没有共享内存锁;相同旧快照产生的两个提交不能都快进发布,失败者返回 CONFLICT。未知发布结果不自动重试,避免自动 id 插入重复。
|
|
95
|
+
|
|
96
|
+
## 6. 传输与错误
|
|
97
|
+
|
|
98
|
+
内部 request 方法统一处理请求发送、HTTP 分类、取消和响应读取,使用 raw 布尔参数区分 JSON 与原始文本,保留现有位置参数,不为这两种读取方式另拆 send/body 抽象。该组织方式不改变取消、投递状态、编码及发布确定性分类的正确性要求。
|
|
99
|
+
|
|
100
|
+
HTTP 请求保留 endpoint 前缀和 GitHub path。GitHub 管理 header 与代理 header 同时发送,不跟随重定向。所有后续 URL/提交使用的 Git SHA 先验证为 40 位小写十六进制。
|
|
101
|
+
|
|
102
|
+
每次请求合并 caller signal 与 timeout,原始数据按 UTF-8 fatal 解码。错误不回显上游正文,保留状态、requestId、retryAfter、dispatched。仅发布 PATCH 的 409/422 归类 CONFLICT/PUBLISH_REJECTED;其他阶段保留 HTTP_ERROR。发布后非确定响应归 WRITE_RESULT_UNKNOWN,并附 commitSha;投递前取消保持 ABORTED。
|
|
103
|
+
|
|
104
|
+
创建 commit 后再取消不会发布 PATCH,但已有对象创建请求,因此 ABORTED 带 dispatched=true、reason=BEFORE_PUBLICATION 及 commitSha。
|
|
105
|
+
|
|
106
|
+
## 7. 构建与验证
|
|
107
|
+
|
|
108
|
+
内部 import/export 省略扩展名。tsc 输出中间声明,Rollup/esbuild 将源码合并并压缩为 lib/index.js,rollup-plugin-dts 合并为 lib/index.d.ts;没有 map 或其他 lib 文件。Node 内置模块保持外部依赖。交付固定为单个压缩 JS 和合并声明,不生成 JS/声明 source map 或按源模块拆分的产物;错误 code/details 仍须保留。声明合并后没有内部文件引用,NodeNext 类型兼容由包消费测试验证。
|
|
109
|
+
|
|
110
|
+
lib 不纳入 Git,prepare 只配置 Husky,不为 Git URL 依赖安装构建。构建使用 macOS/Linux 工具链(包括 rm、npm、tar),不增加 Windows 专用实现或测试矩阵。
|
|
111
|
+
|
|
112
|
+
完整 check 包括类型、lint、格式、Bun 单元及原生 HTTP 覆盖率、构建、Node 原生 HTTP 测试与真实 npm tarball 消费。递归源码清单逐文件检查行/函数覆盖率至少 90%,运行时零行块失败;纯类型声明不参与运行时指标。真实 GitHub 测试独立读取 .env 并清理自己的测试表。
|
|
113
|
+
|
|
114
|
+
注入传输、Bun/Node 原生 fetch 与真实 GitHub 落盘验证不同边界,保留各自必要的 client 配置、协议常量与 Git Data 检查流程,避免实现和断言共享同一错误。普通 meta 夹具共享;独立协议预期在契约变化时仍必须同步复核。
|
|
115
|
+
|
|
116
|
+
Prettier 没有内置 TOML parser,bunfig.toml 保留显式格式排除并手动维护,不为单个短配置增加 TOML 插件。Bun 执行仍必须成功解析配置;格式排除不允许非法配置或降低覆盖率及测试要求。
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# 调用方 API 决策
|
|
2
|
+
|
|
3
|
+
一个 DB 下可以有多张独立表,每张表保存一份 meta.json 和一份 data.jsonl,不提供表分区或可变当前范围。
|
|
4
|
+
|
|
5
|
+
- `db.getTable(name)`:底层 UTF-8 JSONL 字符串接口,支持 read/write/getMeta。
|
|
6
|
+
- `db.getRecordTable<T>(name)`:高层记录接口,支持 insert/find/findById/findAll/updateById/deleteById,必须显式指定包含 RecordBase 的完整业务类型。
|
|
7
|
+
|
|
8
|
+
两个入口使用相同表布局及共享写队列,便于不同调用需求;底层写入的数据只有满足记录约束时才能由高层消费。系统字段由 SDK 管理,业务字段编译期类型由调用方提供。
|
|
9
|
+
|
|
10
|
+
主键只要求表内唯一,另一张表可以复用 id。获取句柄无网络请求。两种 API 都查询整张目标表,表名固定,不支持跨表聚合或事务。
|
|
11
|
+
|
|
12
|
+
示例见 [README](../README.md),详细契约见 [需求](requirements.md) 和 [记录 API](record-api-design.md),实现分层见 [架构](architecture.md)。
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# 类型化记录 API 设计
|
|
2
|
+
|
|
3
|
+
已确认并实现:单表 CRUD、自动/调用方 id、毫秒时间戳、字段等值 AND 查询及运行时记录约束。不提供分区。
|
|
4
|
+
|
|
5
|
+
## 1. 类型与入口
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
interface Todo extends RecordBase {
|
|
9
|
+
title: string;
|
|
10
|
+
status: 'pending' | 'done';
|
|
11
|
+
}
|
|
12
|
+
const todos = db.getRecordTable<Todo>('todolist');
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
T 是完整落盘/返回记录的类型,必须包含字符串 id 与数字 createdAt/updatedAt。工厂不允许省略泛型,系统字段必须能承载 SDK 生成的普通 string/number,不能限定为某个字面量或品牌值。
|
|
16
|
+
|
|
17
|
+
RecordInsert<T> 为业务字段加可选字符串 id,禁止时间字段;RecordUpdate<T> 为业务字段的 Partial,禁止全部系统字段;RecordWhere<T> 为记录字段的 Partial。返回值保持 T、T | null、T[] 或 boolean,不退化成 any。
|
|
18
|
+
|
|
19
|
+
## 2. CRUD 契约
|
|
20
|
+
|
|
21
|
+
insert 首次建表;缺 id 生成 UUID,提供 id 原样使用;两种情况都检查全表唯一性。createdAt/updatedAt 同时由 SDK 设置为系统时钟毫秒值。
|
|
22
|
+
|
|
23
|
+
find 返回首个匹配或 null,findAll 返回全部匹配,无条件时匹配全部。顶层字段按等值 AND 组合;对象按结构、数组按顺序比较,缺字段与 null 区分。结果按文件顺序;findById 通过 find({ id }) 实现。
|
|
24
|
+
|
|
25
|
+
updateById 顶层合并业务字段,嵌套值整体替换,只更新 updatedAt,保留 id/createdAt。空 patch 或系统字段输入为 INVALID_INPUT;不存在为 NOT_FOUND/RECORD_NOT_FOUND。
|
|
26
|
+
|
|
27
|
+
deleteById 存在返回 true,不存在返回 false 且不提交。删除最后一条记录后保留空表,不删除目录。删除后的 id 可再次使用。不同表的主键集合相互独立。
|
|
28
|
+
|
|
29
|
+
读不到表时查询返回 null/[];update 报不存在,delete 返回 false,只有 insert 建表。
|
|
30
|
+
|
|
31
|
+
## 3. 校验边界
|
|
32
|
+
|
|
33
|
+
TS 泛型只提供编译期业务结构约束,不在运行时推断业务字段 schema。运行时检查普通 JSON 对象、字符串主键、安全整数毫秒时间与重复 id。
|
|
34
|
+
|
|
35
|
+
JSON 数据模型允许普通对象、密集数组、合法 Unicode 字符串、有限数字、布尔值和 null;拒绝 undefined、函数、symbol、BigInt、Date/Map/Set、循环引用、访问器、非枚举属性或稀疏/额外属性数组。高精度数字用字符串;已有数值字面量不能作为 JS number 精确往返时拒绝读取,避免后续修改静默丢精度。
|
|
36
|
+
|
|
37
|
+
记录、patch 和查询条件最多 512 层对象/数组容器,根对象计第一层。输入超限在请求前报 INVALID_INPUT/TOO_DEEP;已有记录超限报 INVALID_RECORD/TOO_DEEP 并提供行号。底层字符串接口可存更深的合法 JSONL,不能据此认为它满足记录 API 的契约。id 按原始字符串比较,不自动做 Unicode 规范化。
|
|
38
|
+
|
|
39
|
+
输入在排队前验证并复制,选项也捕获为本次操作的值。读取保存原始行,未触及记录保留原始文本,被更新/插入的记录使用 JSON 序列化。
|
|
40
|
+
|
|
41
|
+
底层 write 不执行业务主键验证。混用两层时需提供符合记录约束的数据,高层发现坏数据或重复记录就失败,不自动修复。
|
|
42
|
+
|
|
43
|
+
## 4. 原子性与代价
|
|
44
|
+
|
|
45
|
+
高层变更复用内部 TableStorage,在 repository 队列内固定快照、验证全表、检查主键、计算修改,并用同一个快照原子发布 data/meta。实例内不同句柄与两层写入共用队列;跨 client 竞争通过非强制分支更新拒绝,无自动重试。
|
|
46
|
+
|
|
47
|
+
发布响应不明确时保留 WRITE_RESULT_UNKNOWN 和尝试 commitSha。自动生成 id 的 insert 同样不能盲目重试,否则可能产生第二条记录。外部 force reset 的严格 CAS 与历史清除不在承诺内。
|
|
48
|
+
|
|
49
|
+
查询和变更都读取完整表;每次写入替换完整 data。没有远端行级索引、分页、SQL、JOIN、batch 或跨表事务。定位为低频、小规模数据,写入容量继承 10,000,000 UTF-8 字节限制。
|
|
50
|
+
|
|
51
|
+
## 5. 验收
|
|
52
|
+
|
|
53
|
+
单元测试涵盖正常 CRUD、查询语义、系统字段、运行时 JSON 校验、主键唯一性、输入复制、坏数据、实例内队列与跨 client 竞争。Node 原生 HTTP、真实 npm 包的 TS 正反例与显式真实 GitHub 单表/多表测试补充协议和交付验证。必须运行完整 bun run check,产品源码逐文件行/函数覆盖率至少 90%。
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# GitHub 数据库 SDK 需求
|
|
2
|
+
|
|
3
|
+
当前规范:单表 JSONL 存储与类型化 CRUD。调用用法见 [README](../README.md),高层接口设计见 [record-api-design](record-api-design.md)。
|
|
4
|
+
|
|
5
|
+
## 1. 环境与连接
|
|
6
|
+
|
|
7
|
+
TypeScript ESM npm SDK,支持 Node.js 24+ 与 Bun。运行与开发环境仅支持 macOS 和 Linux,不支持 Windows。client 配置 token、owner、repo,可选 branch、endpoint、headers、timeout、fetch、apiVersion。仓库与目标分支必须人工保证已有至少一次提交,SDK 不初始化空仓库或创建分支。
|
|
8
|
+
|
|
9
|
+
交付仅提供 ESM import 入口,不提供 CommonJS require/default 入口或 CJS 产物。支持从 npm registry 安装发布包,或在本地构建后使用;不支持直接安装 Git URL 依赖。
|
|
10
|
+
|
|
11
|
+
endpoint 保留路径前缀后拼接 GitHub API path。所有请求、包括 raw 下载都经过该地址;额外代理 header 与 GitHub Bearer token 同时发送,不允许覆盖 SDK 管理的鉴权、媒体类型或 API 版本 header。不跟随重定向,不接受 endpoint 凭据、查询或 fragment。API 日期版本默认 2026-03-10,可配置。
|
|
12
|
+
|
|
13
|
+
## 2. 存储单位
|
|
14
|
+
|
|
15
|
+
一个 DB 是一级目录,一张表是其下的一级目录。每张表固定只有 meta.json 和 data.jsonl 两个受管理文件,所有记录都在同一个 data.jsonl 中,无分区、分片、自动扩容或单记录文件。
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
<db>/<table>/meta.json
|
|
19
|
+
<db>/<table>/data.jsonl
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
名称是非空、可辨识、合法 Unicode 的单个路径段,拒绝分隔符、控制/格式字符、已知空白字形、`.`、`..` 和大小写不敏感的 `.git`;允许普通 Unicode、简单 emoji 与其他点名称。
|
|
23
|
+
|
|
24
|
+
名称校验采用统一的控制/格式字符禁用规则,因此不支持含 ZWJ 的组合 emoji 或含 ZWNJ 的语词;不做字体相关的视觉识别,也不保证穷举所有不可见字形。`.github`、`a..b` 等合法点名称允许使用,不扩大为全部点前缀或含点名称禁用。
|
|
25
|
+
|
|
26
|
+
名称和记录 id 按原始字符串精确匹配,不做 Unicode 规范化,也不要求 NFC。NFC/NFD 同形字符串(例如 `é` 与 `e\u0301`)可对应不同键,不提供模糊匹配或视觉同形键唯一性。自动归一化可能改变已有路径或合并独立主键;如需统一,由调用方在所有读写入口使用 `normalize('NFC')`,处理已有数据时先核查冲突。
|
|
27
|
+
|
|
28
|
+
## 3. 底层 API
|
|
29
|
+
|
|
30
|
+
client.getDB(name).getTable(name) 只创建本地句柄,没有表选项。read 返回 `{ content, meta, version, commitSha }`,write 返回 `{ meta, version, commitSha }`,getMeta 返回 `{ meta, commitSha }`。
|
|
31
|
+
|
|
32
|
+
write 首次自动建表、后续整体替换。read/getMeta 在表不存在时报 NOT_FOUND;空字符串表示空表。expectedVersion 省略表示替换最新表,null 要求不存在,令牌要求匹配。令牌绑定连接作用域、DB、表、实际 data SHA 与表 revision;每次写入递增 revision,其他表更新不影响本表令牌。
|
|
33
|
+
|
|
34
|
+
UTF-8 JSONL 每行必须为对象,允许嵌套业务数据,底层不验证 id。接受 CRLF/缺末尾换行并保留原文本;拒绝 BOM、内部空行、非法 JSON/UTF-8、非对象行或非法 Unicode。单次写入最多 10,000,000 UTF-8 字节,超限报 TABLE_TOO_LARGE;有效超限旧文件允许读取和条件缩小。所有大小统一使用 raw Git Blobs 协议。
|
|
35
|
+
|
|
36
|
+
## 4. 表 meta 与完整性
|
|
37
|
+
|
|
38
|
+
meta 包含 version: 1、createdAt、updatedAt、recordCount、byteLength、revision、dataSha。时间与统计是非负安全整数且拒绝负零,revision 是正安全整数,dataSha 是 40 位小写 SHA。
|
|
39
|
+
|
|
40
|
+
首次写入设置创建/更新时间;后续保留 createdAt,更新 updatedAt、统计、revision 和 SHA。未知扩展字段与数字原始字面量保留。meta/data 在同一 Git commit 中更新并在同一不可变快照中读取;现有文件模式与无关仓库文件保留。
|
|
41
|
+
|
|
42
|
+
损坏目录、缺失必需文件、SHA/统计不符、格式错误必须明确报错,不自动重建,不提供 repair 或强制覆盖接口。现有状态不能可靠推断丢失的创建时间或元信息;恢复由调用方在 SDK 之外核查 Git 历史并恢复一致文件,不能猜测修复或静默覆盖。
|
|
43
|
+
|
|
44
|
+
getMeta 作为管理查询只校验元信息结构,不下载数据,不能单独证明数据文件的 SHA、格式和统计正确;read/write 和 CRUD 必须验证完整表数据。
|
|
45
|
+
|
|
46
|
+
## 5. 记录级 API 与 TS 类型
|
|
47
|
+
|
|
48
|
+
getRecordTable<T>(name) 获取高层句柄,必须显式指定包含 RecordBase 系统字段的完整记录类型。不得省略类型或指定不能承载 SDK 自动生成值的窄系统字段类型。插入、更新、条件字段和值、返回结果都与该类型关联。
|
|
49
|
+
|
|
50
|
+
- insert:输入完整业务字段及可选 id,禁止输入时间字段;省略 id 生成 UUID,提供 id 时原样使用。返回完整记录,首次自动建表。
|
|
51
|
+
- find:通用顶层字段等值 AND 查询,返回文件顺序中的首条记录或 null。
|
|
52
|
+
- findById:调用 find({ id })。
|
|
53
|
+
- findAll:返回全部匹配记录数组,不传条件时查询全表。
|
|
54
|
+
- updateById:输入排除系统字段后的部分业务字段,顶层合并、嵌套字段整体替换,返回完整记录。空 patch 不合法,不存在时报 NOT_FOUND/RECORD_NOT_FOUND。
|
|
55
|
+
- deleteById:存在时删除并返回 true,不存在返回 false,无提交,也不创建空表。
|
|
56
|
+
|
|
57
|
+
id 是非空合法 Unicode 字符串;唯一性范围为一张表,不同表允许复用。SDK 生成数字毫秒 createdAt/updatedAt,更新保留 id 和 createdAt。相同毫秒或系统时钟变化不保证更新时间严格递增。删除最后一条记录后保留表及零字节数据文件。
|
|
58
|
+
|
|
59
|
+
对象等值按结构比较,忽略对象键顺序;数组顺序敏感,null 与缺字段不同。无 SQL、比较操作符、排序、分页、JOIN、跨表事务或 batch API。
|
|
60
|
+
|
|
61
|
+
TS 负责业务类型的编译期校验。运行时验证普通 JSON 对象、系统字段及全表重复 id,不从泛型推导业务 schema。拒绝会丢失的 undefined、函数、symbol、非有限数字、BigInt、循环引用、非普通对象、稀疏/额外属性数组、隐藏属性、访问器及非法 Unicode。读取不能按 JS number 精确往返的原始数字字面量也拒绝;高精度数值应存字符串。
|
|
62
|
+
|
|
63
|
+
记录、patch、查询条件及 meta(含扩展字段)最多 512 层对象/数组容器,根对象算第一层。输入超限在任何请求前报 INVALID_INPUT/TOO_DEEP(含 field);记录读取超限报 INVALID_RECORD/TOO_DEEP(含 lineNumber);meta 超限报 INVALID_METADATA/TOO_DEEP。该上限固定,不提供可配置或无限嵌套支持,使 Node.js/Bun 的可写、可读边界一致。底层 JSONL 字符串接口不设深度限制,高层对底层写入的超限行明确报错,不泄漏原生栈溢出。更深的数据应扁平化,或仅通过底层 API 由调用方处理。
|
|
64
|
+
|
|
65
|
+
底层写入不检查记录业务约束。两层混用时数据须符合记录约束,损坏记录必须报错,不能猜测修复。
|
|
66
|
+
|
|
67
|
+
## 6. 并发、取消与错误
|
|
68
|
+
|
|
69
|
+
底层和高层写操作共享 repository 队列。高层完整读改写都在队列内,唯一性检查、数据修改与提交基于同一个 commit;不能先在队列外读取,再无条件覆盖新快照。
|
|
70
|
+
|
|
71
|
+
force=false 发布拒绝竞争提交,不强制覆盖、不自动重试。外部 force reset/ABA 不在严格 CAS 保证内,不增加针对外部强制历史操作的锁或协调服务;正常非强制并发下仍必须防止丢记录、漏检重复及覆盖其他表。提交前失败不产生可见半更新;发布后网络、取消、超时、5xx 或异常响应可能已经成功,报 WRITE_RESULT_UNKNOWN,携带尝试 commitSha。
|
|
72
|
+
|
|
73
|
+
所有操作支持 AbortSignal。提交信息由 SDK 自动生成,不接受调用方指定,固定为 `github-db: <operation> "<db>/<table>"`,表路径使用 JSON 字符串转义。operation 对应公开 API,为 write、insert、update 或 delete;首次建表沿用调用操作,删除最后一条记录仍为 delete。消息不包含记录内容、ID、时间戳或 SHA。调用入口复制选项与 CRUD 输入,排队期间调用方修改不影响本次操作。错误包含 code 与结构化诊断,不回显上游秘密正文;dispatched 仅表示调用 fetch。
|
|
74
|
+
|
|
75
|
+
创建 Git 对象后、发布前的取消返回 ABORTED,details 为 dispatched=true、reason=BEFORE_PUBLICATION 和 commitSha;没有发送 PATCH。与发布后结果不确定的 WRITE_RESULT_UNKNOWN 区分。
|
|
76
|
+
|
|
77
|
+
## 7. 验收
|
|
78
|
+
|
|
79
|
+
每次代码变更后必须完整 bun run check 通过,产品运行时源码逐文件行覆盖率、函数覆盖率均至少 90%。单元、Bun/Node 原生 HTTP、实际 npm 包消费及 TypeScript 正反例共同验证;显式 test:live 读取 .env,验证真实单表及多表 CRUD/底层读写,并只清理本次生成的表。
|
|
80
|
+
|
|
81
|
+
规模定位为低频、小规模,不承诺无限容量或高频轮询性能。查询整表下载后在内存过滤,写入整表替换;不提供流式读取、远端行级追加或索引。有效超限旧文件允许读取和缩小,不代表无限容量支持。
|
|
82
|
+
|
|
83
|
+
替换、清空或删除记录只影响最新提交,旧内容仍可在可达 Git 历史读取。Git 提交链是存储、原子发布和恢复模型的一部分;SDK 不自动重写或清除历史,历史清理由仓库管理者处理,不能把逻辑删除理解为历史内容已彻底删除。
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# 测试与验收
|
|
2
|
+
|
|
3
|
+
## 1. 必须执行的检查
|
|
4
|
+
|
|
5
|
+
每次代码变更后运行 `bun run check`,依次执行类型检查、lint、格式、Bun 单元及原生 HTTP 测试与覆盖率、构建与 Node 原生 HTTP 测试、实际 npm tarball 的 Node.js/Bun 导入及 NodeNext 类型消费。失败不得标记完成或提交。
|
|
6
|
+
|
|
7
|
+
覆盖率要求产品运行时源码逐文件行和函数均至少 90%。递归加载所有 src 模块,递归源码清单与 LCOV 对照,防止目录拆分或未导入文件绕过门槛。纯类型声明没有运行时计数;运行时零可执行行块直接失败。不得降低阈值、排除产品源码或跳过当前功能测试。
|
|
8
|
+
|
|
9
|
+
Bun LCOV 行/函数统计不能证明所有分支真实执行;已知插桩局限见 [KNOWN-ISSUES](../KNOWN-ISSUES.md)。关键错误及并发路径通过行为断言和 Node 原生 HTTP 测试补强,不以覆盖率数字代替行为验证。
|
|
10
|
+
|
|
11
|
+
## 2. 本地场景
|
|
12
|
+
|
|
13
|
+
| 文件 | 验证范围 |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| client.test.ts | 单表布局、原子提交、meta/data、版本、表隔离、队列及完整性 |
|
|
16
|
+
| jsonl.test.ts | UTF-8/Unicode、JSONL、空文件、边界容量 |
|
|
17
|
+
| transport.test.ts | endpoint、双层鉴权、配置、HTTP、Git 响应、取消及发布确定性 |
|
|
18
|
+
| review-regressions.test.ts / round-two.test.ts | 元信息数字保真、安全整数、SHA、结构化错误、分支发现与历史回归 |
|
|
19
|
+
| records.test.ts | CRUD、AND 等值、系统字段、主键、队列、跨 client 竞争及两层共享 |
|
|
20
|
+
| records-validation.test.ts | JSON 可存储性、坏记录、输入复制、数字精度与容量 |
|
|
21
|
+
| review-latest.test.ts | 512 层边界、深层旧数据结构化错误、发布前取消状态及 Unicode 键隔离 |
|
|
22
|
+
| bun-runtime.test.mjs | Bun 原生 HTTP/fetch 的 UTF-8、大文件、CRUD、双层鉴权、重定向、取消、超时及 socket/发布异常 |
|
|
23
|
+
| coverage.test.mjs | 覆盖率清单、嵌套源码、缺失报告与门槛拒绝 |
|
|
24
|
+
| node-runtime.mjs | 构建后的 ESM 在原生 HTTP/fetch 上的读写、CRUD、并发、超时、取消及丢失发布响应 |
|
|
25
|
+
| package-consumer.mjs | 真正打包、Node/Bun 包名导入、单 JS/d.ts 无 map、NodeNext 正反类型用例 |
|
|
26
|
+
|
|
27
|
+
FakeGitHub 用真实 Git blob SHA、不可变 commit/tree、分支 head 以及非强制更新模拟 Git 对象协议。Bun 单元使用注入 fetch;Bun/Node 原生场景通过本地 HTTP,并保留独立预期。Bun 原生 suite 在默认 bun test 中执行,无需额外脚本;夹具逐测试管理连接,避免 Bun 1.4 的 closeAllConnections 同时停止监听导致二次 close 错误。Node 构建产物另验证 512 层可写可读、超限输入无 HTTP、深层旧行结构化报错。构建产物的公开类型不得因 skipLibCheck=true 而退化。
|
|
28
|
+
|
|
29
|
+
## 3. 真实 GitHub
|
|
30
|
+
|
|
31
|
+
仅显式 `bun run test:live` 写远端。`.env` 配置 GITHUB_TOKEN、GITHUB_OWNER、GITHUB_REPO、GITHUB_DB_NAME,不提交凭据、不输出值。仓库与目标分支必须已有提交,测试不负责初始化。
|
|
32
|
+
|
|
33
|
+
第三方 relay 验收同时配置 `GITHUB_RELAY_ENDPOINT` 和 `GITHUB_RELAY_TOKEN`,缺少任意一项时立即失败。SDK、独立 Git Data 核验及清理请求均通过该 endpoint,并在 GitHub Bearer 鉴权之外发送 `x-cfgr-token`。两项均未配置时直连 GitHub。
|
|
34
|
+
|
|
35
|
+
每次生成唯一表名前缀,独立 Git Data 请求核验初始不存在、同次提交的 meta/data 和最新单表布局。覆盖底层单表、多个独立表、条件版本、零字节文件、大于 1 MB 的 raw blob,以及高层自动/指定 id、重复拒绝、AND 查询、更新、删除、并发新增与跨表隔离。
|
|
36
|
+
|
|
37
|
+
结束时仅移除本次生成的表目录,核验清理成功;不会清理其他数据或重写 Git 历史。明确的清理发布竞争可在新快照重新计算,未知发布结果/网络失败不盲目重试;清理失败指出本轮表名并保留原因。
|
|
38
|
+
|
|
39
|
+
## 4. 平台与构建
|
|
40
|
+
|
|
41
|
+
只支持 macOS/Linux 的 Node.js 24+/Bun,不支持 Windows。完整检查生成单个压缩 lib/index.js 和合并 lib/index.d.ts,无任何 map 或内部声明依赖。npm publish 的 prepublishOnly 执行 check,check 的 Node 测试先构建,发布 prepack 不重复构建;npm pack 的 prepack 自动构建。
|
package/lib/index.d.ts
CHANGED
|
@@ -1 +1,212 @@
|
|
|
1
|
-
|
|
1
|
+
interface ClientOptions {
|
|
2
|
+
token: string;
|
|
3
|
+
owner: string;
|
|
4
|
+
repo: string;
|
|
5
|
+
branch?: string;
|
|
6
|
+
endpoint?: string;
|
|
7
|
+
/** GitHub REST API date version; default 2026-03-10. Set the version supported by your proxy/GHES. */
|
|
8
|
+
apiVersion?: string;
|
|
9
|
+
headers?: RequestInit['headers'];
|
|
10
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
11
|
+
/** Per-request milliseconds, between 1 and 2,147,483,647 (Node.js timer limit). */
|
|
12
|
+
timeout?: number;
|
|
13
|
+
}
|
|
14
|
+
interface ReadOptions {
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
interface TableMeta {
|
|
18
|
+
/** Metadata format version, not the write counter. */
|
|
19
|
+
version: 1;
|
|
20
|
+
/** Unix timestamps in milliseconds. */
|
|
21
|
+
createdAt: number;
|
|
22
|
+
updatedAt: number;
|
|
23
|
+
recordCount: number;
|
|
24
|
+
byteLength: number;
|
|
25
|
+
revision: number;
|
|
26
|
+
/** Exact Git blob identity registered in meta.json. */
|
|
27
|
+
dataSha: string;
|
|
28
|
+
}
|
|
29
|
+
declare const tableVersionBrand: unique symbol;
|
|
30
|
+
/** Opaque token from read()/write(). Pass it back unchanged. */
|
|
31
|
+
type TableVersion = string & {
|
|
32
|
+
readonly [tableVersionBrand]: true;
|
|
33
|
+
};
|
|
34
|
+
interface TableWriteOptions extends ReadOptions {
|
|
35
|
+
/** Omit to replace the latest table; null requires its absence. */
|
|
36
|
+
expectedVersion?: TableVersion | null;
|
|
37
|
+
}
|
|
38
|
+
interface TableMetaResult {
|
|
39
|
+
meta: TableMeta;
|
|
40
|
+
commitSha: string;
|
|
41
|
+
}
|
|
42
|
+
interface TableWriteResult extends TableMetaResult {
|
|
43
|
+
version: TableVersion;
|
|
44
|
+
}
|
|
45
|
+
interface TableReadResult extends TableWriteResult {
|
|
46
|
+
content: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** GitHub-compatible HTTP transport, including configuration and response handling. */
|
|
50
|
+
declare class GitHubTransport {
|
|
51
|
+
private readonly endpoint;
|
|
52
|
+
private readonly prefix;
|
|
53
|
+
private readonly headers;
|
|
54
|
+
private readonly fetcher;
|
|
55
|
+
private readonly timeout;
|
|
56
|
+
constructor(options: ClientOptions);
|
|
57
|
+
request(path: string, method: string, body: unknown, raw: boolean, signal?: AbortSignal): Promise<unknown>;
|
|
58
|
+
json(path: string, method?: string, body?: unknown, signal?: AbortSignal): Promise<Record<string, unknown>>;
|
|
59
|
+
scope(branch: string | undefined): string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface GitEntry {
|
|
63
|
+
path: string;
|
|
64
|
+
sha: string;
|
|
65
|
+
type: string;
|
|
66
|
+
mode: string;
|
|
67
|
+
}
|
|
68
|
+
interface GitSnapshot {
|
|
69
|
+
commitSha: string;
|
|
70
|
+
treeSha: string;
|
|
71
|
+
trees: Map<string, Promise<GitEntry[]>>;
|
|
72
|
+
}
|
|
73
|
+
type Change = {
|
|
74
|
+
path: string;
|
|
75
|
+
mode?: string;
|
|
76
|
+
} & ({
|
|
77
|
+
content: string;
|
|
78
|
+
} | {
|
|
79
|
+
sha: string;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Resolve regular files and directories within one immutable Git snapshot. */
|
|
83
|
+
declare class GitTreeReader {
|
|
84
|
+
private readonly transport;
|
|
85
|
+
constructor(transport: GitHubTransport);
|
|
86
|
+
private tree;
|
|
87
|
+
private resolve;
|
|
88
|
+
find(snapshot: GitSnapshot, path: string, signal?: AbortSignal): Promise<GitEntry | undefined>;
|
|
89
|
+
directory(snapshot: GitSnapshot, path: string, signal?: AbortSignal): Promise<GitEntry[] | undefined>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Internal Git adapter for table snapshots and atomic metadata/data publication. */
|
|
93
|
+
declare class GitRepository {
|
|
94
|
+
private readonly transport;
|
|
95
|
+
readonly files: GitTreeReader;
|
|
96
|
+
private branchName;
|
|
97
|
+
private branchLookup;
|
|
98
|
+
private queue;
|
|
99
|
+
constructor(options: ClientOptions);
|
|
100
|
+
private branch;
|
|
101
|
+
snapshot(signal?: AbortSignal): Promise<GitSnapshot>;
|
|
102
|
+
/** Version-token scope; snapshot() resolves the effective branch before use. */
|
|
103
|
+
scope(): string;
|
|
104
|
+
content(entry: GitEntry, signal?: AbortSignal): Promise<string>;
|
|
105
|
+
enqueue<T>(operation: () => Promise<T>): Promise<T>;
|
|
106
|
+
createBlob(content: string, signal?: AbortSignal): Promise<string>;
|
|
107
|
+
commit(snapshot: GitSnapshot, changes: Change[], message: string, signal?: AbortSignal): Promise<{
|
|
108
|
+
commitSha: string;
|
|
109
|
+
}>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Include these SDK-managed fields in the caller's record interface. */
|
|
113
|
+
interface RecordBase {
|
|
114
|
+
id: string;
|
|
115
|
+
createdAt: number;
|
|
116
|
+
updatedAt: number;
|
|
117
|
+
}
|
|
118
|
+
type RecordInsert<T extends RecordBase> = Omit<T, keyof RecordBase> & {
|
|
119
|
+
id?: string;
|
|
120
|
+
createdAt?: never;
|
|
121
|
+
updatedAt?: never;
|
|
122
|
+
};
|
|
123
|
+
type RecordUpdate<T extends RecordBase> = Partial<Omit<T, keyof RecordBase>> & {
|
|
124
|
+
id?: never;
|
|
125
|
+
createdAt?: never;
|
|
126
|
+
updatedAt?: never;
|
|
127
|
+
};
|
|
128
|
+
/** Top-level field equality; multiple fields are combined with AND. */
|
|
129
|
+
type RecordWhere<T extends RecordBase> = Partial<T>;
|
|
130
|
+
type RecordWriteOptions = ReadOptions;
|
|
131
|
+
/** Prevent omitted generics and narrowed SDK-managed fields from promising invalid return types. */
|
|
132
|
+
type RecordTableName<T extends RecordBase> = [T] extends [never] ? never : RecordBase extends Pick<T, keyof RecordBase> ? string : never;
|
|
133
|
+
|
|
134
|
+
/** Typed CRUD for one table. T supplies compile-time business types, not a runtime schema. */
|
|
135
|
+
declare class GitHubRecordTable<T extends RecordBase> {
|
|
136
|
+
private readonly repository;
|
|
137
|
+
readonly dbName: string;
|
|
138
|
+
readonly name: string;
|
|
139
|
+
private readonly storage;
|
|
140
|
+
constructor(repository: GitRepository, dbName: string, name: string);
|
|
141
|
+
findAll(where?: RecordWhere<T>, options?: ReadOptions): Promise<T[]>;
|
|
142
|
+
find(where?: RecordWhere<T>, options?: ReadOptions): Promise<T | null>;
|
|
143
|
+
findById(id: string, options?: ReadOptions): Promise<T | null>;
|
|
144
|
+
insert(input: RecordInsert<T>, options?: RecordWriteOptions): Promise<T>;
|
|
145
|
+
updateById(id: string, patch: RecordUpdate<T>, options?: RecordWriteOptions): Promise<T>;
|
|
146
|
+
deleteById(id: string, options?: RecordWriteOptions): Promise<boolean>;
|
|
147
|
+
private mutate;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One logical table stored as meta.json and data.jsonl. */
|
|
151
|
+
declare class GitHubTable {
|
|
152
|
+
private readonly repository;
|
|
153
|
+
readonly dbName: string;
|
|
154
|
+
readonly name: string;
|
|
155
|
+
private readonly storage;
|
|
156
|
+
constructor(repository: GitRepository, dbName: string, name: string);
|
|
157
|
+
private version;
|
|
158
|
+
read(options?: ReadOptions): Promise<TableReadResult>;
|
|
159
|
+
/** Replace the table atomically. Repositories and branches must already have a commit. */
|
|
160
|
+
write(content: string, options?: TableWriteOptions): Promise<TableWriteResult>;
|
|
161
|
+
/** Read only meta.json; use read() to also verify data and statistics. */
|
|
162
|
+
getMeta(options?: ReadOptions): Promise<TableMetaResult>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
declare class GitHubDatabase {
|
|
166
|
+
private readonly repository;
|
|
167
|
+
readonly name: string;
|
|
168
|
+
constructor(repository: GitRepository, name: string);
|
|
169
|
+
/** List table names in one snapshot, without downloading metadata or data. */
|
|
170
|
+
listTables(options?: ReadOptions): Promise<string[]>;
|
|
171
|
+
/** Local text handle; no requests or commits until an operation is called. */
|
|
172
|
+
getTable(name: string): GitHubTable;
|
|
173
|
+
/** Specify the complete record type, including id and SDK-managed timestamps. */
|
|
174
|
+
getRecordTable<T extends RecordBase = never>(name: RecordTableName<T>): GitHubRecordTable<T>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* GitHub-backed UTF-8 JSONL tables for Node.js and Bun.
|
|
179
|
+
* 使用方必须人工保证 GitHub 仓库至少已有一次提交,且目标分支已存在。
|
|
180
|
+
* 本 SDK 不负责空仓库初始化或创建分支。
|
|
181
|
+
*/
|
|
182
|
+
declare class GitHubDBClient {
|
|
183
|
+
private readonly repository;
|
|
184
|
+
constructor(options: ClientOptions);
|
|
185
|
+
/** Create a local database handle without requests or Git commits. */
|
|
186
|
+
getDB(name: string): GitHubDatabase;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
type GitHubDBErrorCode = 'INVALID_INPUT' | 'INVALID_UTF8' | 'INVALID_JSONL' | 'TABLE_TOO_LARGE' | 'INVALID_RECORD' | 'DUPLICATE_ID' | 'INVALID_RESPONSE' | 'HTTP_ERROR' | 'NETWORK_ERROR' | 'ABORTED' | 'TIMEOUT' | 'NOT_FOUND' | 'INVALID_METADATA' | 'CONFLICT' | 'WRITE_RESULT_UNKNOWN';
|
|
190
|
+
interface ErrorDetails {
|
|
191
|
+
/** Stable machine-readable cause; code remains the broad category. */
|
|
192
|
+
reason?: string;
|
|
193
|
+
path?: string;
|
|
194
|
+
table?: string;
|
|
195
|
+
field?: string;
|
|
196
|
+
/** True once fetch is invoked; does not prove that the server received a request. */
|
|
197
|
+
dispatched?: boolean;
|
|
198
|
+
lineNumber?: number;
|
|
199
|
+
status?: number;
|
|
200
|
+
requestId?: string;
|
|
201
|
+
retryAfter?: string;
|
|
202
|
+
commitSha?: string;
|
|
203
|
+
}
|
|
204
|
+
declare class GitHubDBError extends Error {
|
|
205
|
+
readonly code: GitHubDBErrorCode;
|
|
206
|
+
readonly details: ErrorDetails;
|
|
207
|
+
readonly name = "GitHubDBError";
|
|
208
|
+
constructor(code: GitHubDBErrorCode, message: string, details?: ErrorDetails);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export { GitHubDBClient, GitHubDBError, GitHubDatabase, GitHubRecordTable, GitHubTable };
|
|
212
|
+
export type { ClientOptions, ErrorDetails, GitHubDBErrorCode, ReadOptions, RecordBase, RecordInsert, RecordUpdate, RecordWhere, RecordWriteOptions, TableMeta, TableMetaResult, TableReadResult, TableVersion, TableWriteOptions, TableWriteResult };
|
package/lib/index.js
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import{randomUUID as H,createHash as J}from"node:crypto";import{isDeepStrictEqual as B}from"node:util";import{Buffer as _}from"node:buffer";class o extends Error{constructor(t,e,a={}){super(e),this.code=t,this.details=a}code;details;name="GitHubDBError"}function b(s){return s!==null&&typeof s=="object"&&!Array.isArray(s)}function L(s){if(!b(s))throw new o("INVALID_INPUT","Options must be an object.")}function O(s){if(s!==void 0&&!(s instanceof AbortSignal))throw new o("INVALID_INPUT","signal must be an AbortSignal.")}function F(s){if(typeof s!="string"||!s.isWellFormed())throw new o("INVALID_INPUT","Content must be a well-formed Unicode string.")}function R(s){return typeof s=="string"&&!!s.trim()&&s.isWellFormed()&&s!=="."&&s!==".."&&s.toLowerCase()!==".git"&&!/[/\\\p{Cc}\p{Cf}\u115F\u1160\u3164\u2800\uFFA0]/u.test(s)}function w(s){if(!R(s))throw new o("INVALID_INPUT","Names must be nonempty, unambiguous path segments.");return s}function I(s){L(s);const t=s.signal;return O(t),t}const G=1e7;function S(s,t=!0){F(s);const e=_.byteLength(s,"utf8");if(t&&e>G)throw new o("TABLE_TOO_LARGE","Table exceeds 10,000,000 UTF-8 bytes.");if(s==="")return{recordCount:0,byteLength:0};const a=s.split(`
|
|
2
|
+
`);a.at(-1)===""&&a.pop();for(const[r,i]of a.entries()){let n;try{n=JSON.parse(i)}catch{throw new o("INVALID_JSONL","Every record line must be valid JSON.",{lineNumber:r+1})}if(!b(n))throw new o("INVALID_JSONL","Every record must be a JSON object.",{lineNumber:r+1})}return{recordCount:a.length,byteLength:e}}const q=512;function v(s,t,e){if(s>q)throw new o(t,"JSON nesting exceeds 512 object/array containers.",{...e,reason:"TOO_DEEP"})}function P(s,t,e){let a=0,r=!1;for(let i=0;i<s.length;i++){const n=s[i];r&&n==="\\"?i++:n==='"'?r=!r:r||(n==="{"||n==="["?v(++a,t,e):(n==="}"||n==="]")&&a--)}}function V(s){const t=/^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(s),e=t[3]??"",a=(t[2]+e).replace(/^0+/,""),r=a.replace(/0+$/,"");return{sign:a?t[1]:"",digits:r,exponent:a?BigInt(t[4]??0)-BigInt(e.length)+BigInt(a.length-r.length):0n}}const g=JSON;function k(s){const{digits:t,exponent:e}=V(s);return!t||e>=0n}function E(s,t,e){const a=s[t],r=g.isRawJSON(a)?Number(a.rawJSON):a;if(typeof r!="number"||!Number.isSafeInteger(r)||r<0||Object.is(r,-0)||g.isRawJSON(a)&&!k(a.rawJSON))throw new o("INVALID_METADATA","Metadata field must be a nonnegative safe integer.",{...e,reason:"INVALID_FIELD",field:t});return s[t]=r,r}function W(s,t){P(s,"INVALID_METADATA",t);let e;try{e=g.parse(s,(a,r,i)=>typeof r=="number"&&JSON.stringify(r)!==i.source?g.rawJSON(i.source):r)}catch{throw new o("INVALID_METADATA","meta.json must contain valid JSON.",{...t,reason:"INVALID_JSON"})}if(!b(e))throw new o("INVALID_METADATA","meta.json must contain an object.",{...t,reason:"INVALID_FIELD"});if(E(e,"version",t)!==1)throw new o("INVALID_METADATA","Unsupported metadata format version.",{...t,reason:"UNSUPPORTED_VERSION",field:"version"});for(const a of["createdAt","updatedAt","recordCount","byteLength"])E(e,a,t);if(E(e,"revision",t)===0||typeof e.dataSha!="string"||!/^[a-f0-9]{40}$/.test(e.dataSha))throw new o("INVALID_METADATA","Table revision or dataSha is invalid.",{...t,reason:"INVALID_FIELD",field:"revision/dataSha"});return e}function Y(s){return JSON.stringify(s,null,2)+`
|
|
3
|
+
`}class U{constructor(t,e,a){this.repository=t,this.dbName=e,this.name=a,w(e),w(a),this.root=`${e}/${a}`,this.metaPath=`${this.root}/meta.json`,this.dataPath=`${this.root}/data.jsonl`}repository;dbName;name;root;metaPath;dataPath;context(t=this.metaPath){return{path:t,table:this.name}}async metadata(t,e){const a=await this.repository.files.find(t,this.metaPath,e);if(!a){if((await this.repository.files.directory(t,this.root,e))?.length)throw new o("INVALID_METADATA","Existing table is missing meta.json.",{...this.context(),reason:"MISSING_METADATA"});return}return{meta:W(await this.repository.content(a,e),this.context()),entry:a}}async state(t,e){const[a,r]=await Promise.all([this.metadata(t,e),this.repository.files.find(t,this.dataPath,e)]);if(a&&!r)throw new o("INVALID_METADATA","Table is missing data.jsonl.",{...this.context(),path:this.dataPath,reason:"MISSING_DATA"});return{metadata:a,data:r}}async validatedContent(t,e){if(!t.metadata||!t.data)return"";const{meta:a}=t.metadata;if(a.dataSha!==t.data.sha)throw new o("INVALID_METADATA","dataSha does not match data.jsonl.",{...this.context(),path:this.dataPath,reason:"DATA_SHA_MISMATCH"});const r=await this.repository.content(t.data,e),i=S(r,!1);if(i.recordCount!==a.recordCount||i.byteLength!==a.byteLength)throw new o("INVALID_METADATA","Statistics do not match data.jsonl.",{...this.context(),path:this.dataPath,reason:"STATISTICS_MISMATCH"});return r}async write(t,e,a,r,i,n){const h=Date.now(),d=(e.metadata?.meta.revision??0)+1;if(!Number.isSafeInteger(d))throw new o("INVALID_METADATA","Table revision cannot be incremented safely.",{...this.context(),reason:"REVISION_OVERFLOW",field:"revision"});const u=await this.repository.createBlob(a,n),c={...e.metadata?.meta,version:1,createdAt:e.metadata?.meta.createdAt??h,updatedAt:h,...r,revision:d,dataSha:u},p=await this.repository.commit(t,[{path:this.dataPath,sha:u,mode:e.data?.mode},{path:this.metaPath,content:Y(c),mode:e.metadata?.entry.mode}],`github-db: ${i} ${JSON.stringify(this.root)}`,n);return{meta:c,commitSha:p.commitSha}}}function T(s,t="INVALID_INPUT",e={}){const a=()=>{throw new o(t,"Expected a plain JSON object with only serializable values.",{...e,reason:"NOT_JSON"})},r=new Set,i=(n,h,d)=>{if(n===null||typeof n=="boolean")return;if(typeof n=="string"){n.isWellFormed()||a();return}if(typeof n=="number"){Number.isFinite(n)||a();return}if(typeof n!="object"||n===null)return a();v(h,t,{...e,field:d});const u=Array.isArray(n),c=Object.getPrototypeOf(n);((u?c!==Array.prototype:c!==Object.prototype&&c!==null)||r.has(n))&&a(),r.add(n);const p=Reflect.ownKeys(n);u&&p.length!==n.length+1&&a();for(const l of p){if(u&&l==="length")continue;if(typeof l!="string"||!l.isWellFormed())return a();u&&(!/^(0|[1-9]\d*)$/.test(l)||Number(l)>=n.length)&&a();const f=Object.getOwnPropertyDescriptor(n,l);(!f.enumerable||!("value"in f))&&a(),i(f.value,h+1,`${d}.${l}`)}r.delete(n)};try{return b(s)?(i(s,1,"$"),JSON.parse(JSON.stringify(s))):a()}catch(n){if(n instanceof o)throw n;return a()}}function N(s,t="INVALID_INPUT",e={}){if(typeof s!="string"||!s.trim()||!s.isWellFormed())throw new o(t,"id must be a nonempty, well-formed string.",{...e,reason:"INVALID_ID",field:"id"})}function C(s){const{sign:t,digits:e,exponent:a}=V(s);return e?`${t}${e}e${a}`:"0"}const z=JSON.parse;function j(s,t){if(!s)return[];const e=s.split(`
|
|
4
|
+
`);e.at(-1)===""&&e.pop();const a=new Set;return e.map((r,i)=>{const n={...t,lineNumber:i+1};P(r,"INVALID_RECORD",n);let h;try{h=z(r,(u,c,p)=>{if(typeof c=="number"&&(!Number.isFinite(c)||C(p.source)!==C(JSON.stringify(c))))throw new o("INVALID_RECORD","Record number cannot round-trip without losing precision.",{...t,lineNumber:i+1,reason:"NUMERIC_PRECISION"});return c})}catch(u){throw u instanceof o?u:new o("INVALID_RECORD","Record line could not be parsed.",{...n,reason:"UNPARSABLE"})}const d=T(h,"INVALID_RECORD",n);N(d.id,"INVALID_RECORD",n);for(const u of["createdAt","updatedAt"]){const c=h[u];if(typeof c!="number"||!Number.isSafeInteger(c)||c<0||Object.is(c,-0))throw new o("INVALID_RECORD","Record timestamps must be nonnegative safe integers.",{...n,reason:"INVALID_TIMESTAMP",field:u})}if(a.has(d.id))throw new o("DUPLICATE_ID","Duplicate id in table data.",{...n,reason:"DUPLICATE_ID",field:"id"});return a.add(d.id),{record:d,source:r}})}function K(s){return s.length?s.map(t=>t.source).join(`
|
|
5
|
+
`)+`
|
|
6
|
+
`:""}class x{constructor(t,e,a){this.repository=t,this.dbName=e,this.name=a,this.storage=new U(t,e,a)}repository;dbName;name;storage;async findAll(t={},e={}){const a=I(e),r=T(t),i=await this.repository.snapshot(a),n=await this.storage.state(i,a);return j(await this.storage.validatedContent(n,a),this.storage.context(this.storage.dataPath)).filter(({record:h})=>Object.entries(r).every(([d,u])=>Object.hasOwn(h,d)&&B(h[d],u))).map(({record:h})=>h)}async find(t={},e={}){return(await this.findAll(t,e))[0]??null}async findById(t,e={}){return N(t),this.find({id:t},e)}async insert(t,e={}){const a=T(t);if(Object.hasOwn(a,"createdAt")||Object.hasOwn(a,"updatedAt"))throw new o("INVALID_INPUT","Record timestamps are managed by the SDK.");const r=Object.hasOwn(a,"id")?a.id:H();return N(r),this.mutate("insert",i=>{if(i.some(({record:d})=>d.id===r))throw new o("DUPLICATE_ID","id already exists in this table.",{...this.storage.context(this.storage.dataPath),reason:"DUPLICATE_ID",field:"id"});const n=Date.now(),h={...a,id:r,createdAt:n,updatedAt:n};return i.push({record:h,source:JSON.stringify(h)}),{result:h,write:!0}},e)}async updateById(t,e,a={}){N(t);const r=T(e);if(!Object.keys(r).length||["id","createdAt","updatedAt"].some(i=>Object.hasOwn(r,i)))throw new o("INVALID_INPUT","Update requires business fields and cannot change system fields.");return this.mutate("update",i=>{const n=i.find(({record:d})=>d.id===t);if(!n)throw new o("NOT_FOUND","Record does not exist.",{...this.storage.context(this.storage.dataPath),reason:"RECORD_NOT_FOUND"});const h={...n.record,...r,updatedAt:Date.now()};return n.record=h,n.source=JSON.stringify(h),{result:h,write:!0}},a)}async deleteById(t,e={}){return N(t),this.mutate("delete",a=>{const r=a.findIndex(({record:i})=>i.id===t);return r<0?{result:!1,write:!1}:(a.splice(r,1),{result:!0,write:!0})},e)}async mutate(t,e,a){const r=I(a);return this.repository.enqueue(async()=>{const i=await this.repository.snapshot(r),n=await this.storage.state(i,r),h=j(await this.storage.validatedContent(n,r),this.storage.context(this.storage.dataPath)),d=e(h);if(d.write){const u=K(h);await this.storage.write(i,n,u,S(u),t,r)}return d.result})}}class ${constructor(t,e,a){this.repository=t,this.dbName=e,this.name=a,this.storage=new U(t,e,a)}repository;dbName;name;storage;version(t,e){return J("sha256").update(JSON.stringify([this.repository.scope(),this.dbName,this.name,t.revision,e])).digest("hex")}async read(t={}){const e=I(t),a=await this.repository.snapshot(e),r=await this.storage.state(a,e);if(!r.metadata||!r.data)throw new o("NOT_FOUND","Table does not exist.",{...this.storage.context(),path:this.storage.dataPath,reason:"TABLE_NOT_FOUND"});return{content:await this.storage.validatedContent(r,e),meta:r.metadata.meta,version:this.version(r.metadata.meta,r.data.sha),commitSha:a.commitSha}}async write(t,e={}){const a=I(e),r=e.expectedVersion;if(r!=null&&(typeof r!="string"||!/^[a-f0-9]{64}$/.test(r)))throw new o("INVALID_INPUT","expectedVersion must be an opaque table version or null.");const i=S(t);return this.repository.enqueue(async()=>{const n=await this.repository.snapshot(a),h=await this.storage.state(n,a),d=h.metadata&&h.data?this.version(h.metadata.meta,h.data.sha):null;if(r!==void 0&&r!==d)throw new o("CONFLICT","Table version does not match expectedVersion.",{...this.storage.context(),path:this.storage.dataPath,reason:"VERSION_MISMATCH"});await this.storage.validatedContent(h,a);const u=await this.storage.write(n,h,t,i,"write",a);return{...u,version:this.version(u.meta,u.meta.dataSha)}})}async getMeta(t={}){const e=I(t),a=await this.repository.snapshot(e),r=await this.storage.metadata(a,e);if(!r)throw new o("NOT_FOUND","Table does not exist.",{...this.storage.context(),reason:"TABLE_NOT_FOUND"});return{meta:r.meta,commitSha:a.commitSha}}}class M{constructor(t,e){this.repository=t,this.name=e,w(e)}repository;name;async listTables(t={}){const e=I(t),a=await this.repository.snapshot(e),r=await this.repository.files.directory(a,this.name,e);return(await Promise.all((r??[]).filter(i=>i.type==="tree"&&R(i.path)).map(async i=>{const n=await this.repository.files.directory(a,`${this.name}/${i.path}`,e),h=new Set(n?.filter(d=>d.type==="blob"&&["100644","100755"].includes(d.mode)).map(d=>d.path));return h.has("meta.json")&&h.has("data.jsonl")?i.path:void 0}))).filter(i=>i!==void 0).sort()}getTable(t){return new $(this.repository,this.name,t)}getRecordTable(t){return new x(this.repository,this.name,t)}}function y(s){if(!b(s))throw new o("INVALID_RESPONSE","Expected a GitHub response object.");return s}function m(s){if(typeof s!="string"||!/^[a-f0-9]{40}$/.test(s))throw new o("INVALID_RESPONSE","Expected a 40-character lowercase Git SHA.");return s}function D(s){if(typeof s!="string"||!s)throw new o("INVALID_RESPONSE","Expected a nonempty GitHub response field.");return s}class X{endpoint;prefix;headers;fetcher;timeout;constructor(t){L(t);for(const a of[t.token,t.owner,t.repo])if(typeof a!="string"||!a.trim())throw new o("INVALID_INPUT","token, owner and repo are required.");if(w(t.owner),w(t.repo),t.branch!==void 0&&(typeof t.branch!="string"||!t.branch.trim()||!t.branch.isWellFormed()||/[\p{Cc}\p{Cf}]/u.test(t.branch)))throw new o("INVALID_INPUT","branch must be nonempty, well-formed Unicode without control or format characters.");let e;try{e=new URL(t.endpoint??"https://api.github.com")}catch{throw new o("INVALID_INPUT","endpoint must be an absolute HTTP(S) URL.")}if(!["https:","http:"].includes(e.protocol)||e.search||e.hash||e.username||e.password)throw new o("INVALID_INPUT","endpoint cannot contain credentials, query or fragment.");if(t.timeout!==void 0&&(!Number.isSafeInteger(t.timeout)||t.timeout<=0||t.timeout>2147483647))throw new o("INVALID_INPUT","timeout must be between 1 and 2,147,483,647 milliseconds.");if(t.apiVersion!==void 0&&(typeof t.apiVersion!="string"||!/^\d{4}-\d{2}-\d{2}$/.test(t.apiVersion)))throw new o("INVALID_INPUT","apiVersion must have YYYY-MM-DD format.");if(t.fetch!==void 0&&typeof t.fetch!="function")throw new o("INVALID_INPUT","fetch must be a function.");this.endpoint=e.href.replace(/\/+$/,""),this.prefix=`/repos/${encodeURIComponent(t.owner)}/${encodeURIComponent(t.repo)}`;try{this.headers=new Headers(t.headers)}catch{throw new o("INVALID_INPUT","Custom headers are not valid HTTP header values.")}for(const a of["authorization","accept","content-type","x-github-api-version"])if(this.headers.has(a))throw new o("INVALID_INPUT",`Custom headers cannot override ${a}.`);try{this.headers.set("Authorization",`Bearer ${t.token}`)}catch{throw new o("INVALID_INPUT","token is not a valid HTTP header value.")}this.headers.set("X-GitHub-Api-Version",t.apiVersion??"2026-03-10"),this.fetcher=t.fetch??globalThis.fetch,this.timeout=t.timeout}async request(t,e,a,r,i){O(i);let n=!1;const h=new Headers(this.headers);h.set("Accept",r?"application/vnd.github.raw+json":"application/vnd.github+json"),a!==void 0&&h.set("Content-Type","application/json");const d=i?[i]:[],u=this.timeout===void 0?void 0:AbortSignal.timeout(this.timeout);u&&d.push(u);const c=d.length?AbortSignal.any(d):void 0,p=()=>new o(i?.aborted?"ABORTED":"TIMEOUT",i?.aborted?"Request was cancelled.":"Request timed out.",{dispatched:n});if(c?.aborted)throw p();let l;try{n=!0,l=await this.fetcher(`${this.endpoint}${this.prefix}${t}`,{method:e,headers:h,body:a===void 0?void 0:JSON.stringify(a),redirect:"manual",signal:c})}catch{throw c?.aborted?p():new o("NETWORK_ERROR","GitHub request failed.",{dispatched:n})}if(!(l instanceof Response))throw new o("INVALID_RESPONSE","fetch must return a Response.",{dispatched:n});const f={dispatched:n,status:l.status,requestId:l.headers.get("x-github-request-id")??void 0,retryAfter:l.headers.get("retry-after")??void 0};if(!l.ok)throw new o("HTTP_ERROR",`GitHub request returned HTTP ${l.status}.`,{...f,...l.status>=300&&l.status<400?{reason:"REDIRECT"}:{}});try{if(!r)return await l.json();const A=await l.arrayBuffer();try{return new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}).decode(A)}catch{throw new o("INVALID_UTF8","File content is not valid UTF-8.",f)}}catch(A){throw c?.aborted?p():A instanceof o?A:new o("INVALID_RESPONSE","Could not read the GitHub response.",f)}}async json(t,e="GET",a,r){return y(await this.request(t,e,a,!1,r))}scope(t){return JSON.stringify([this.endpoint,this.prefix,t])}}function Q(s){if(typeof s!="string")throw new o("INVALID_INPUT","Path must be a string.");return s.split("/").forEach(w),s}class Z{constructor(t){this.transport=t}transport;tree(t,e,a){let r=t.trees.get(e);return r||(r=this.transport.json(`/git/trees/${e}`,"GET",void 0,a).then(i=>{if(i.truncated!==!1||!Array.isArray(i.tree))throw new o("INVALID_RESPONSE","GitHub returned an incomplete tree.");return i.tree.map(n=>{const h=y(n);return{path:D(h.path),sha:m(h.sha),type:D(h.type),mode:D(h.mode)}})}),t.trees.set(e,r)),r}async resolve(t,e,a){const r=Q(e).split("/");let i=t.treeSha;for(let n=0;n<r.length;n++){const h=(await this.tree(t,i,a)).find(d=>d.path===r[n]);if(!h)return;if(n===r.length-1)return h;if(h.type!=="tree")throw new o("INVALID_METADATA","A parent path is not a directory.",{path:r.slice(0,n+1).join("/"),reason:"PATH_NOT_DIRECTORY"});i=h.sha}}async find(t,e,a){const r=await this.resolve(t,e,a);if(r&&(r.type!=="blob"||!["100644","100755"].includes(r.mode)))throw new o("INVALID_METADATA","Path must reference a regular file.",{path:e,reason:"NOT_REGULAR_FILE"});return r}async directory(t,e,a){const r=await this.resolve(t,e,a);if(r){if(r.type!=="tree")throw new o("INVALID_METADATA","Path must reference a directory.",{path:e,reason:"PATH_NOT_DIRECTORY"});return this.tree(t,r.sha,a)}}}class tt{transport;files;branchName;branchLookup;queue=Promise.resolve();constructor(t){this.transport=new X(t),this.files=new Z(this.transport),this.branchName=t.branch}async branch(t){if(t?.aborted)throw new o("ABORTED","Request was cancelled.",{dispatched:!1});if(this.branchName!==void 0)return this.branchName;if(this.branchLookup??=this.transport.json("").then(a=>(this.branchName=D(a.default_branch),this.branchName)).finally(()=>{this.branchLookup=void 0}),!t)return this.branchLookup;const e=this.branchLookup;if(t.aborted)throw e.catch(()=>{}),new o("ABORTED","Request was cancelled.",{dispatched:!0});return new Promise((a,r)=>{const i=()=>r(new o("ABORTED","Request was cancelled.",{dispatched:!0}));t.addEventListener("abort",i,{once:!0}),e.then(a,r).finally(()=>t.removeEventListener("abort",i))})}async snapshot(t){O(t);const e=await this.branch(t),a=await this.transport.json(`/git/ref/heads/${encodeURIComponent(e)}`,"GET",void 0,t),r=m(y(a.object).sha),i=await this.transport.json(`/git/commits/${r}`,"GET",void 0,t);return{commitSha:r,treeSha:m(y(i.tree).sha),trees:new Map}}scope(){return this.transport.scope(this.branchName)}async content(t,e){return await this.transport.request(`/git/blobs/${t.sha}`,"GET",void 0,!0,e)}enqueue(t){const e=this.queue.then(t);return this.queue=e.then(()=>{},()=>{}),e}async createBlob(t,e){const a=await this.transport.json("/git/blobs","POST",{content:_.from(t,"utf8").toString("base64"),encoding:"base64"},e);return m(a.sha)}async commit(t,e,a,r){const i=await Promise.all(e.map(async c=>({path:c.path,mode:c.mode??"100644",type:"blob",sha:"sha"in c?c.sha:await this.createBlob(c.content,r)}))),n=await this.transport.json("/git/trees","POST",{base_tree:t.treeSha,tree:i},r),h=await this.transport.json("/git/commits","POST",{tree:m(n.sha),parents:[t.commitSha],message:a},r),d=m(h.sha),u=await this.branch();if(r?.aborted)throw new o("ABORTED","Write was cancelled before publication.",{dispatched:!0,reason:"BEFORE_PUBLICATION",commitSha:d});try{const c=await this.transport.json(`/git/refs/heads/${encodeURIComponent(u)}`,"PATCH",{sha:d,force:!1},r);if(m(y(c.object).sha)!==d)throw new o("INVALID_RESPONSE","Unexpected published commit.")}catch(c){if(c instanceof o){if(c.details.dispatched===!1)throw c;if(c.code==="HTTP_ERROR"&&c.details.status!==void 0&&c.details.status<500)throw c.details.status===409||c.details.status===422?new o("CONFLICT","GitHub rejected publication; inspect the cause before retrying.",{...c.details,reason:"PUBLISH_REJECTED"}):c}throw new o("WRITE_RESULT_UNKNOWN","Publication may have succeeded; verify before retrying.",{dispatched:!0,...c instanceof o?c.details:{},commitSha:d})}return{commitSha:d}}}class et{repository;constructor(t){this.repository=new tt(t)}getDB(t){return new M(this.repository,t)}}export{et as GitHubDBClient,o as GitHubDBError,M as GitHubDatabase,x as GitHubRecordTable,$ as GitHubTable};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@secret-momo/github-db",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A lightweight wrapper that turns a GitHub repository into a simple database.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.4.2",
|
|
@@ -20,7 +20,13 @@
|
|
|
20
20
|
"url": "https://github.com/secretmomo/github-db/issues"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
|
-
"lib"
|
|
23
|
+
"lib",
|
|
24
|
+
"KNOWN-ISSUES.md",
|
|
25
|
+
"docs/requirements.md",
|
|
26
|
+
"docs/architecture.md",
|
|
27
|
+
"docs/testing.md",
|
|
28
|
+
"docs/caller-api-design.md",
|
|
29
|
+
"docs/record-api-design.md"
|
|
24
30
|
],
|
|
25
31
|
"main": "./lib/index.js",
|
|
26
32
|
"types": "./lib/index.d.ts",
|
|
@@ -32,16 +38,19 @@
|
|
|
32
38
|
},
|
|
33
39
|
"scripts": {
|
|
34
40
|
"prepare": "bunx --no-install husky",
|
|
35
|
-
"prepack": "
|
|
41
|
+
"prepack": "node scripts/prepack.mjs",
|
|
36
42
|
"prepublishOnly": "bun run check",
|
|
37
43
|
"typecheck": "tsc --noEmit",
|
|
38
44
|
"lint": "eslint . --max-warnings 0",
|
|
39
45
|
"lint:fix": "eslint . --fix",
|
|
40
46
|
"format": "prettier . --write",
|
|
41
47
|
"format:check": "prettier . --check",
|
|
42
|
-
"test": "bun test --
|
|
43
|
-
"
|
|
44
|
-
"
|
|
48
|
+
"test": "bun test --coverage && node scripts/check-coverage.mjs",
|
|
49
|
+
"test:node": "bun run build && node --test tests/node-runtime.mjs",
|
|
50
|
+
"test:live": "bun run build && node --env-file=.env --test tests/live-github.mjs",
|
|
51
|
+
"check": "bun run typecheck && bun run lint && bun run format:check && bun run test && bun run test:node && bun run test:types",
|
|
52
|
+
"build": "rm -rf lib .cache/declarations && tsc -p tsconfig.build.json && rollup -c && rm -rf .cache/declarations",
|
|
53
|
+
"test:types": "node tests/package-consumer.mjs"
|
|
45
54
|
},
|
|
46
55
|
"lint-staged": {
|
|
47
56
|
"*.{ts,tsx,js,jsx,mjs,cjs}": [
|
|
@@ -63,8 +72,10 @@
|
|
|
63
72
|
"lint-staged": "17.5.1",
|
|
64
73
|
"prettier": "3.9.8",
|
|
65
74
|
"rollup": "4.63.4",
|
|
75
|
+
"rollup-plugin-dts": "6.5.1",
|
|
66
76
|
"rollup-plugin-esbuild": "6.2.1",
|
|
67
77
|
"typescript": "6.0.3",
|
|
68
78
|
"typescript-eslint": "8.70.0"
|
|
69
|
-
}
|
|
79
|
+
},
|
|
80
|
+
"license": "MIT"
|
|
70
81
|
}
|