@alteran/astro 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +558 -0
- package/index.d.ts +12 -0
- package/index.js +129 -0
- package/package.json +75 -0
- package/src/_worker.ts +44 -0
- package/src/app.ts +10 -0
- package/src/db/client.ts +7 -0
- package/src/db/dal.ts +97 -0
- package/src/db/repo.ts +135 -0
- package/src/db/schema.ts +89 -0
- package/src/db/seed.ts +14 -0
- package/src/env.d.ts +4 -0
- package/src/handlers/debug.ts +34 -0
- package/src/handlers/health.ts +6 -0
- package/src/handlers/ready.ts +14 -0
- package/src/handlers/root.ts +5 -0
- package/src/handlers/wellknown.ts +7 -0
- package/src/handlers/xrpc.repo.core.ts +57 -0
- package/src/handlers/xrpc.server.createSession.ts +25 -0
- package/src/handlers/xrpc.server.refreshSession.ts +43 -0
- package/src/lib/auth.ts +20 -0
- package/src/lib/blockstore-gc.ts +197 -0
- package/src/lib/cache.ts +236 -0
- package/src/lib/car-reader.ts +157 -0
- package/src/lib/commit-log-pruning.ts +76 -0
- package/src/lib/commit.ts +162 -0
- package/src/lib/config.ts +208 -0
- package/src/lib/errors.ts +142 -0
- package/src/lib/firehose/frames.ts +229 -0
- package/src/lib/firehose/parse.ts +82 -0
- package/src/lib/firehose/validation.ts +9 -0
- package/src/lib/handle.ts +90 -0
- package/src/lib/jwt.ts +150 -0
- package/src/lib/logger.ts +73 -0
- package/src/lib/metrics.ts +194 -0
- package/src/lib/mst/blockstore.ts +105 -0
- package/src/lib/mst/index.ts +3 -0
- package/src/lib/mst/mst.ts +643 -0
- package/src/lib/mst/util.ts +86 -0
- package/src/lib/ratelimit.ts +34 -0
- package/src/lib/sequencer.ts +10 -0
- package/src/lib/streaming-car.ts +137 -0
- package/src/lib/token-cleanup.ts +38 -0
- package/src/lib/tracing.ts +136 -0
- package/src/lib/util.ts +55 -0
- package/src/middleware.ts +102 -0
- package/src/pages/.well-known/atproto-did.ts +7 -0
- package/src/pages/.well-known/did.json.ts +76 -0
- package/src/pages/debug/blob/[...key].ts +27 -0
- package/src/pages/debug/db/bootstrap.ts +23 -0
- package/src/pages/debug/db/commits.ts +20 -0
- package/src/pages/debug/gc/blobs.ts +16 -0
- package/src/pages/debug/record.ts +33 -0
- package/src/pages/health.ts +68 -0
- package/src/pages/index.astro +57 -0
- package/src/pages/index.ts +2 -0
- package/src/pages/ready.ts +16 -0
- package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +38 -0
- package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +45 -0
- package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +73 -0
- package/src/pages/xrpc/com.atproto.repo.createRecord.ts +36 -0
- package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +36 -0
- package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +51 -0
- package/src/pages/xrpc/com.atproto.repo.getRecord.ts +25 -0
- package/src/pages/xrpc/com.atproto.repo.listRecords.ts +57 -0
- package/src/pages/xrpc/com.atproto.repo.putRecord.ts +36 -0
- package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +53 -0
- package/src/pages/xrpc/com.atproto.server.createSession.ts +92 -0
- package/src/pages/xrpc/com.atproto.server.deleteSession.ts +25 -0
- package/src/pages/xrpc/com.atproto.server.describeServer.ts +17 -0
- package/src/pages/xrpc/com.atproto.server.getSession.ts +46 -0
- package/src/pages/xrpc/com.atproto.server.refreshSession.ts +67 -0
- package/src/pages/xrpc/com.atproto.sync.getBlocks.json.ts +16 -0
- package/src/pages/xrpc/com.atproto.sync.getBlocks.ts +56 -0
- package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +20 -0
- package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +43 -0
- package/src/pages/xrpc/com.atproto.sync.getHead.ts +11 -0
- package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +42 -0
- package/src/pages/xrpc/com.atproto.sync.getRecord.ts +63 -0
- package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +20 -0
- package/src/pages/xrpc/com.atproto.sync.getRepo.range.ts +34 -0
- package/src/pages/xrpc/com.atproto.sync.getRepo.ts +17 -0
- package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +53 -0
- package/src/pages/xrpc/com.atproto.sync.listRepos.ts +31 -0
- package/src/services/car.ts +249 -0
- package/src/services/r2-blob-store.ts +87 -0
- package/src/services/repo-manager.ts +339 -0
- package/src/shims/astro-internal-handler.d.ts +4 -0
- package/src/worker/sequencer.ts +563 -0
- package/types/env.d.ts +48 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { Env } from '../env';
|
|
2
|
+
import { listRecords } from '../db/dal';
|
|
3
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
4
|
+
import { desc, and, gte, lte } from 'drizzle-orm';
|
|
5
|
+
import { commit_log } from '../db/schema';
|
|
6
|
+
import { CID } from 'multiformats/cid';
|
|
7
|
+
import * as dagCbor from '@ipld/dag-cbor';
|
|
8
|
+
import { sha256 } from 'multiformats/hashes/sha2';
|
|
9
|
+
import { MST, Leaf, D1Blockstore } from '../lib/mst';
|
|
10
|
+
|
|
11
|
+
export type CarSnapshot = {
|
|
12
|
+
bytes: Uint8Array;
|
|
13
|
+
root: CID;
|
|
14
|
+
blocks: { cid: CID; bytes: Uint8Array }[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export async function encodeRecordBlock(value: unknown) {
|
|
18
|
+
const bytes = dagCbor.encode(value);
|
|
19
|
+
const hash = await sha256.digest(bytes);
|
|
20
|
+
const cid = CID.createV1(dagCbor.code, hash);
|
|
21
|
+
return { cid, bytes } as const;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function buildRepoCar(env: Env, did: string): Promise<CarSnapshot> {
|
|
25
|
+
// Prefer the latest signed commit from commit_log (authoritative root)
|
|
26
|
+
const db = drizzle(env.DB);
|
|
27
|
+
const tip = await db.select().from(commit_log).orderBy(desc(commit_log.seq)).limit(1).get();
|
|
28
|
+
|
|
29
|
+
if (tip) {
|
|
30
|
+
try {
|
|
31
|
+
// Reconstruct the exact signed commit object that produced tip.cid
|
|
32
|
+
const parsed = JSON.parse(tip.data);
|
|
33
|
+
const prevStr = parsed.prev ?? null;
|
|
34
|
+
const signedCommit = {
|
|
35
|
+
did: parsed.did as string,
|
|
36
|
+
version: parsed.version as number,
|
|
37
|
+
data: CID.parse(String(parsed.data)),
|
|
38
|
+
rev: String(parsed.rev),
|
|
39
|
+
prev: prevStr ? CID.parse(String(prevStr)) : null,
|
|
40
|
+
sig: (() => {
|
|
41
|
+
const bin = atob(String(tip.sig));
|
|
42
|
+
const u8 = new Uint8Array(bin.length);
|
|
43
|
+
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
|
44
|
+
return u8;
|
|
45
|
+
})(),
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
// Encode to CBOR and verify CID matches tip
|
|
49
|
+
const commitBytes = dagCbor.encode(signedCommit);
|
|
50
|
+
const hash = await sha256.digest(commitBytes);
|
|
51
|
+
const commitCid = CID.createV1(dagCbor.code, hash);
|
|
52
|
+
|
|
53
|
+
if (commitCid.toString() === (tip as any).cid) {
|
|
54
|
+
// Build a full snapshot CAR: commit block + all MST nodes + all record blocks
|
|
55
|
+
const blockstore = new D1Blockstore(env);
|
|
56
|
+
const blocks: { cid: CID; bytes: Uint8Array }[] = [{ cid: commitCid, bytes: commitBytes }];
|
|
57
|
+
const seen = new Set<string>([commitCid.toString()]);
|
|
58
|
+
|
|
59
|
+
const addBlock = async (cid: CID) => {
|
|
60
|
+
const key = cid.toString();
|
|
61
|
+
if (seen.has(key)) return;
|
|
62
|
+
const bytes = await blockstore.get(cid);
|
|
63
|
+
if (bytes) {
|
|
64
|
+
seen.add(key);
|
|
65
|
+
blocks.push({ cid, bytes });
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const mstRoot = CID.parse(String(parsed.data));
|
|
70
|
+
// 1) Add all MST node blocks
|
|
71
|
+
await addMstBlocks(blockstore, mstRoot, seen, blocks);
|
|
72
|
+
|
|
73
|
+
// 2) Add all record leaf blocks by walking the MST
|
|
74
|
+
try {
|
|
75
|
+
const mst = MST.load(blockstore, mstRoot);
|
|
76
|
+
for await (const leaf of mst.walkLeavesFrom('')) {
|
|
77
|
+
await addBlock(leaf.value);
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.warn('Snapshot: failed traversing MST leaves:', e);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const bytes = encodeCar([commitCid], blocks);
|
|
84
|
+
return { bytes, root: commitCid, blocks };
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// Fall through to deterministic snapshot
|
|
88
|
+
console.warn('Failed to reconstruct signed commit from tip; falling back to snapshot:', e);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Fallback: deterministic snapshot built from current records
|
|
93
|
+
const rows = await listRecords(env);
|
|
94
|
+
const blocks: { cid: CID; bytes: Uint8Array }[] = [];
|
|
95
|
+
for (const r of rows) {
|
|
96
|
+
if (!r.uri.startsWith(`at://${did}/`)) continue;
|
|
97
|
+
const value = JSON.parse(r.json);
|
|
98
|
+
const block = await encodeRecordBlock(value);
|
|
99
|
+
blocks.push(block);
|
|
100
|
+
}
|
|
101
|
+
const commitObj = { type: 'commit', did, records: blocks.map((b) => b.cid.toString()).sort() };
|
|
102
|
+
const commit = await encodeRecordBlock(commitObj);
|
|
103
|
+
const bytes = encodeCar([commit.cid], [...blocks, commit]);
|
|
104
|
+
return { bytes, root: commit.cid, blocks: [...blocks, commit] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function buildRepoCarRange(env: Env, fromSeq: number, toSeq: number): Promise<CarSnapshot> {
|
|
108
|
+
const db = drizzle(env.DB);
|
|
109
|
+
const rows = await db.select().from(commit_log).where(and(gte(commit_log.seq, fromSeq), lte(commit_log.seq, toSeq))).all();
|
|
110
|
+
const blocks: { cid: CID; bytes: Uint8Array }[] = [];
|
|
111
|
+
for (const r of rows) {
|
|
112
|
+
const b = await encodeRecordBlock({ type: 'commit', rev: r.rev, head: r.cid, ts: r.ts });
|
|
113
|
+
blocks.push(b);
|
|
114
|
+
}
|
|
115
|
+
const root = blocks[blocks.length - 1]?.cid ?? (await encodeRecordBlock({})).cid;
|
|
116
|
+
const bytes = encodeCar([root], blocks);
|
|
117
|
+
return { bytes, root, blocks };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function buildBlocksCar(values: unknown[]): Promise<CarSnapshot> {
|
|
121
|
+
const blocks: { cid: CID; bytes: Uint8Array }[] = [];
|
|
122
|
+
for (const v of values) {
|
|
123
|
+
const block = await encodeRecordBlock(v);
|
|
124
|
+
blocks.push(block);
|
|
125
|
+
}
|
|
126
|
+
const root = blocks[0]?.cid ?? (await encodeRecordBlock({})).cid;
|
|
127
|
+
const bytes = encodeCar([root], blocks);
|
|
128
|
+
return { bytes, root, blocks };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Encode a list of already-encoded blocks into a CAR v1 file.
|
|
133
|
+
*/
|
|
134
|
+
export function encodeBlocksToCAR(root: CID, blocks: { cid: CID; bytes: Uint8Array }[]): Uint8Array {
|
|
135
|
+
return encodeCar([root], blocks);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function encodeExistingBlocksToCAR(roots: CID[], blocks: { cid: CID; bytes: Uint8Array }[]): Uint8Array {
|
|
139
|
+
return encodeCar(roots, blocks);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function concat(parts: Uint8Array[]): Uint8Array {
|
|
143
|
+
const size = parts.reduce((n, p) => n + p.byteLength, 0);
|
|
144
|
+
const buf = new Uint8Array(size);
|
|
145
|
+
let off = 0;
|
|
146
|
+
for (const p of parts) { buf.set(p, off); off += p.byteLength; }
|
|
147
|
+
return buf;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function varint(n: number): Uint8Array {
|
|
151
|
+
const bytes: number[] = [];
|
|
152
|
+
while (n >= 0x80) {
|
|
153
|
+
bytes.push((n & 0x7f) | 0x80);
|
|
154
|
+
n >>>= 7;
|
|
155
|
+
}
|
|
156
|
+
bytes.push(n);
|
|
157
|
+
return new Uint8Array(bytes);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function encodeCar(roots: CID[], blocks: { cid: CID; bytes: Uint8Array }[]): Uint8Array {
|
|
161
|
+
const header = dagCbor.encode({ version: 1, roots });
|
|
162
|
+
const chunks: Uint8Array[] = [];
|
|
163
|
+
chunks.push(varint(header.byteLength));
|
|
164
|
+
chunks.push(header);
|
|
165
|
+
for (const { cid, bytes } of blocks) {
|
|
166
|
+
const block = concat([cid.bytes, bytes]);
|
|
167
|
+
chunks.push(varint(block.byteLength));
|
|
168
|
+
chunks.push(block);
|
|
169
|
+
}
|
|
170
|
+
return concat(chunks);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Encode blocks for firehose commit frame
|
|
175
|
+
* Includes commit block, MST nodes, and record blocks
|
|
176
|
+
*/
|
|
177
|
+
export async function encodeBlocksForCommit(
|
|
178
|
+
env: Env,
|
|
179
|
+
commitCid: CID,
|
|
180
|
+
mstRoot: CID,
|
|
181
|
+
ops: Array<{ path: string; cid: CID | null }>,
|
|
182
|
+
): Promise<Uint8Array> {
|
|
183
|
+
const blockstore = new D1Blockstore(env);
|
|
184
|
+
const blocks: { cid: CID; bytes: Uint8Array }[] = [];
|
|
185
|
+
const seen = new Set<string>();
|
|
186
|
+
|
|
187
|
+
// Helper to add block if not already seen
|
|
188
|
+
const addBlock = async (cid: CID) => {
|
|
189
|
+
const cidStr = cid.toString();
|
|
190
|
+
if (seen.has(cidStr)) return;
|
|
191
|
+
seen.add(cidStr);
|
|
192
|
+
|
|
193
|
+
const bytes = await blockstore.get(cid);
|
|
194
|
+
if (bytes) {
|
|
195
|
+
blocks.push({ cid, bytes });
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// 1. Add commit block
|
|
200
|
+
await addBlock(commitCid);
|
|
201
|
+
|
|
202
|
+
// 2. Add MST nodes by traversing the tree
|
|
203
|
+
await addMstBlocks(blockstore, mstRoot, seen, blocks);
|
|
204
|
+
|
|
205
|
+
// 3. Add record blocks for all operations
|
|
206
|
+
for (const op of ops) {
|
|
207
|
+
if (op.cid) {
|
|
208
|
+
await addBlock(op.cid);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Encode as CAR with commit as root
|
|
213
|
+
return encodeCar([commitCid], blocks);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Recursively add all MST node blocks
|
|
218
|
+
*/
|
|
219
|
+
async function addMstBlocks(
|
|
220
|
+
blockstore: D1Blockstore,
|
|
221
|
+
rootCid: CID,
|
|
222
|
+
seen: Set<string>,
|
|
223
|
+
blocks: { cid: CID; bytes: Uint8Array }[],
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
const cidStr = rootCid.toString();
|
|
226
|
+
if (seen.has(cidStr)) return;
|
|
227
|
+
seen.add(cidStr);
|
|
228
|
+
|
|
229
|
+
// Add the MST node block itself
|
|
230
|
+
const bytes = await blockstore.get(rootCid);
|
|
231
|
+
if (!bytes) return;
|
|
232
|
+
blocks.push({ cid: rootCid, bytes });
|
|
233
|
+
|
|
234
|
+
// Load MST and traverse children
|
|
235
|
+
try {
|
|
236
|
+
const mst = MST.load(blockstore, rootCid);
|
|
237
|
+
const entries = await mst.getEntries();
|
|
238
|
+
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
if (entry.isTree()) {
|
|
241
|
+
// Recursively add child MST blocks
|
|
242
|
+
const childCid = await entry.getPointer();
|
|
243
|
+
await addMstBlocks(blockstore, childCid, seen, blocks);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} catch (error) {
|
|
247
|
+
console.error('Error traversing MST:', error);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Types via tsconfig.app.json
|
|
2
|
+
import type { R2ObjectBody } from '@cloudflare/workers-types';
|
|
3
|
+
import type { Env } from '../env';
|
|
4
|
+
|
|
5
|
+
export type PutOptions = {
|
|
6
|
+
contentType?: string;
|
|
7
|
+
maxBytes?: number; // default from env or 5 MiB
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type PutResult = {
|
|
11
|
+
key: string;
|
|
12
|
+
size: number;
|
|
13
|
+
sha256: string; // base64url
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type BlobBody = ArrayBuffer | ArrayBufferView;
|
|
17
|
+
|
|
18
|
+
export class R2BlobStore {
|
|
19
|
+
constructor(private env: Env) {}
|
|
20
|
+
|
|
21
|
+
private maxBytes(defaultMax = 5 * 1024 * 1024): number {
|
|
22
|
+
const raw = (this.env as any).PDS_MAX_BLOB_SIZE as string | undefined;
|
|
23
|
+
const n = raw ? Number(raw) : defaultMax;
|
|
24
|
+
return Number.isFinite(n) && n > 0 ? n : defaultMax;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private static asUint8Array(data: BlobBody): Uint8Array {
|
|
28
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
29
|
+
if (ArrayBuffer.isView(data)) {
|
|
30
|
+
const view = data as ArrayBufferView;
|
|
31
|
+
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
32
|
+
}
|
|
33
|
+
// Fallback should never happen because BlobBody restricts input, but keep typing satisfied
|
|
34
|
+
return new Uint8Array(data as ArrayBuffer);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private static toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
|
38
|
+
const bufferLike = data.buffer;
|
|
39
|
+
if (bufferLike instanceof ArrayBuffer) {
|
|
40
|
+
if (data.byteOffset === 0 && data.byteLength === bufferLike.byteLength) {
|
|
41
|
+
return bufferLike;
|
|
42
|
+
}
|
|
43
|
+
return bufferLike.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
44
|
+
}
|
|
45
|
+
const buffer = new ArrayBuffer(data.byteLength);
|
|
46
|
+
new Uint8Array(buffer).set(data);
|
|
47
|
+
return buffer;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private static b64url(bytes: ArrayBuffer | Uint8Array): string {
|
|
51
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
52
|
+
let s = '';
|
|
53
|
+
for (const v of view) s += String.fromCharCode(v);
|
|
54
|
+
return btoa(s).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private static hex(bytes: Uint8Array): string {
|
|
58
|
+
return Array.from(bytes).map((v) => v.toString(16).padStart(2, '0')).join('');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private static cidKey(shaB64url: string, prefix = 'blobs/by-cid/'): string {
|
|
62
|
+
return `${prefix}${shaB64url}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async put(body: BlobBody, opts: PutOptions = {}): Promise<PutResult> {
|
|
66
|
+
const view = R2BlobStore.asUint8Array(body);
|
|
67
|
+
const size = view.byteLength;
|
|
68
|
+
const limit = opts.maxBytes ?? this.maxBytes();
|
|
69
|
+
if (size > limit) throw new Error(`BlobTooLarge:${size}>${limit}`);
|
|
70
|
+
|
|
71
|
+
const contentType = opts.contentType ?? 'application/octet-stream';
|
|
72
|
+
const sha = await crypto.subtle.digest('SHA-256', view);
|
|
73
|
+
const shaB64 = R2BlobStore.b64url(sha);
|
|
74
|
+
const key = R2BlobStore.cidKey(shaB64);
|
|
75
|
+
const buffer = R2BlobStore.toArrayBuffer(view);
|
|
76
|
+
await this.env.BLOBS.put(key, buffer, { httpMetadata: { contentType } });
|
|
77
|
+
return { key, size, sha256: shaB64 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async get(key: string): Promise<R2ObjectBody | null> {
|
|
81
|
+
return this.env.BLOBS.get(key);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async delete(key: string): Promise<void> {
|
|
85
|
+
await this.env.BLOBS.delete(key);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { CID } from 'multiformats/cid';
|
|
2
|
+
import type { Env } from '../env';
|
|
3
|
+
import { MST, D1Blockstore, Leaf } from '../lib/mst';
|
|
4
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
5
|
+
import { repo_root } from '../db/schema';
|
|
6
|
+
import { eq } from 'drizzle-orm';
|
|
7
|
+
import type { RepoOp } from '../lib/firehose/frames';
|
|
8
|
+
import * as dagCbor from '@ipld/dag-cbor';
|
|
9
|
+
import { cidForCbor } from '../lib/mst/util';
|
|
10
|
+
import { putRecord as dalPutRecord, deleteRecord as dalDeleteRecord } from '../db/dal';
|
|
11
|
+
import { bumpRoot } from '../db/repo';
|
|
12
|
+
import { generateTid } from '../lib/commit';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Repository Manager
|
|
16
|
+
* Manages MST-based repository operations
|
|
17
|
+
*/
|
|
18
|
+
export class RepoManager {
|
|
19
|
+
private blockstore: D1Blockstore;
|
|
20
|
+
private did: string;
|
|
21
|
+
|
|
22
|
+
constructor(private env: Env) {
|
|
23
|
+
this.blockstore = new D1Blockstore(env);
|
|
24
|
+
this.did = env.PDS_DID ?? 'did:example:single-user';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get the current MST root
|
|
29
|
+
*/
|
|
30
|
+
async getRoot(): Promise<MST | null> {
|
|
31
|
+
const db = drizzle(this.env.DB);
|
|
32
|
+
const row = await db
|
|
33
|
+
.select()
|
|
34
|
+
.from(repo_root)
|
|
35
|
+
.where(eq(repo_root.did, this.did))
|
|
36
|
+
.get();
|
|
37
|
+
|
|
38
|
+
if (!row || !row.commitCid) return null;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const rootCid = CID.parse(row.commitCid);
|
|
42
|
+
return MST.load(this.blockstore, rootCid);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get or create the MST root
|
|
50
|
+
*/
|
|
51
|
+
async getOrCreateRoot(): Promise<MST> {
|
|
52
|
+
const existing = await this.getRoot();
|
|
53
|
+
if (existing) return existing;
|
|
54
|
+
|
|
55
|
+
// Create new empty MST
|
|
56
|
+
const mst = await MST.create(this.blockstore, []);
|
|
57
|
+
return mst;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Add a record to the repository
|
|
62
|
+
*/
|
|
63
|
+
async addRecord(collection: string, rkey: string, record: unknown): Promise<{
|
|
64
|
+
mst: MST;
|
|
65
|
+
recordCid: CID;
|
|
66
|
+
prevMstRoot: CID | null;
|
|
67
|
+
}> {
|
|
68
|
+
const key = `${collection}/${rkey}`;
|
|
69
|
+
|
|
70
|
+
// Get previous MST root for op extraction
|
|
71
|
+
const currentMst = await this.getOrCreateRoot();
|
|
72
|
+
const prevMstRoot = await currentMst.getPointer();
|
|
73
|
+
|
|
74
|
+
// Encode record and store in blockstore
|
|
75
|
+
const recordCid = await this.storeRecord(record);
|
|
76
|
+
|
|
77
|
+
// Add the new record
|
|
78
|
+
const newMst = await currentMst.add(key, recordCid);
|
|
79
|
+
|
|
80
|
+
// Store all new MST blocks
|
|
81
|
+
await this.storeMstBlocks(newMst);
|
|
82
|
+
|
|
83
|
+
return { mst: newMst, recordCid, prevMstRoot };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* High-level helper: create record, persist JSON, bump root, return commit info
|
|
88
|
+
*/
|
|
89
|
+
async createRecord(collection: string, record: unknown, rkey?: string): Promise<{
|
|
90
|
+
uri: string;
|
|
91
|
+
cid: string;
|
|
92
|
+
commitCid: string;
|
|
93
|
+
rev: string;
|
|
94
|
+
ops: RepoOp[];
|
|
95
|
+
}> {
|
|
96
|
+
const key = rkey ?? generateTid();
|
|
97
|
+
const { mst, recordCid, prevMstRoot } = await this.addRecord(collection, key, record);
|
|
98
|
+
|
|
99
|
+
// Persist JSON to table for easy reads
|
|
100
|
+
const uri = `at://${this.did}/${collection}/${key}`;
|
|
101
|
+
await dalPutRecord(this.env, { uri, did: this.did, cid: recordCid.toString(), json: JSON.stringify(record) } as any);
|
|
102
|
+
|
|
103
|
+
// Update repo root with signed commit and extract ops
|
|
104
|
+
const { commitCid, rev, ops } = await bumpRoot(this.env, prevMstRoot ?? undefined);
|
|
105
|
+
|
|
106
|
+
return { uri, cid: recordCid.toString(), commitCid, rev, ops };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Update a record in the repository
|
|
111
|
+
*/
|
|
112
|
+
async updateRecord(collection: string, rkey: string, record: unknown): Promise<{
|
|
113
|
+
mst: MST;
|
|
114
|
+
recordCid: CID;
|
|
115
|
+
prevMstRoot: CID | null;
|
|
116
|
+
}> {
|
|
117
|
+
const key = `${collection}/${rkey}`;
|
|
118
|
+
|
|
119
|
+
// Get previous MST root for op extraction
|
|
120
|
+
const currentMst = await this.getOrCreateRoot();
|
|
121
|
+
const prevMstRoot = await currentMst.getPointer();
|
|
122
|
+
|
|
123
|
+
// Encode record and store in blockstore
|
|
124
|
+
const recordCid = await this.storeRecord(record);
|
|
125
|
+
|
|
126
|
+
// Update the record
|
|
127
|
+
const newMst = await currentMst.update(key, recordCid);
|
|
128
|
+
|
|
129
|
+
// Store all new MST blocks
|
|
130
|
+
await this.storeMstBlocks(newMst);
|
|
131
|
+
|
|
132
|
+
return { mst: newMst, recordCid, prevMstRoot };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* High-level helper: put record (update), persist JSON, bump root
|
|
137
|
+
*/
|
|
138
|
+
async putRecord(collection: string, rkey: string, record: unknown): Promise<{
|
|
139
|
+
uri: string;
|
|
140
|
+
cid: string;
|
|
141
|
+
commitCid: string;
|
|
142
|
+
rev: string;
|
|
143
|
+
ops: RepoOp[];
|
|
144
|
+
}> {
|
|
145
|
+
const { mst, recordCid, prevMstRoot } = await this.updateRecord(collection, rkey, record);
|
|
146
|
+
const uri = `at://${this.did}/${collection}/${rkey}`;
|
|
147
|
+
await dalPutRecord(this.env, { uri, did: this.did, cid: recordCid.toString(), json: JSON.stringify(record) } as any);
|
|
148
|
+
const { commitCid, rev, ops } = await bumpRoot(this.env, prevMstRoot ?? undefined);
|
|
149
|
+
return { uri, cid: recordCid.toString(), commitCid, rev, ops };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Delete a record from the repository
|
|
154
|
+
*/
|
|
155
|
+
async deleteRecord(collection: string, rkey: string): Promise<{
|
|
156
|
+
uri: string;
|
|
157
|
+
commitCid: string;
|
|
158
|
+
rev: string;
|
|
159
|
+
ops: RepoOp[];
|
|
160
|
+
}> {
|
|
161
|
+
const key = `${collection}/${rkey}`;
|
|
162
|
+
|
|
163
|
+
// Get previous MST root for op extraction
|
|
164
|
+
const currentMst = await this.getOrCreateRoot();
|
|
165
|
+
const prevMstRoot = await currentMst.getPointer();
|
|
166
|
+
|
|
167
|
+
// Delete the record
|
|
168
|
+
const newMst = await currentMst.delete(key);
|
|
169
|
+
|
|
170
|
+
// Store all new MST blocks
|
|
171
|
+
await this.storeMstBlocks(newMst);
|
|
172
|
+
|
|
173
|
+
// Delete from records table
|
|
174
|
+
const uri = `at://${this.did}/${collection}/${rkey}`;
|
|
175
|
+
await dalDeleteRecord(this.env, uri);
|
|
176
|
+
|
|
177
|
+
const { commitCid, rev, ops } = await bumpRoot(this.env, prevMstRoot ?? undefined);
|
|
178
|
+
return { uri, commitCid, rev, ops };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get a record from the repository
|
|
183
|
+
*/
|
|
184
|
+
async getRecord(collection: string, rkey: string): Promise<unknown | null> {
|
|
185
|
+
const key = `${collection}/${rkey}`;
|
|
186
|
+
|
|
187
|
+
const currentMst = await this.getRoot();
|
|
188
|
+
if (!currentMst) return null;
|
|
189
|
+
|
|
190
|
+
const recordCid = await currentMst.get(key);
|
|
191
|
+
if (!recordCid) return null;
|
|
192
|
+
|
|
193
|
+
return this.blockstore.readObj(recordCid);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* List records in a collection
|
|
198
|
+
*/
|
|
199
|
+
async listRecords(collection: string, limit = 50, cursor?: string): Promise<{ key: string; cid: CID }[]> {
|
|
200
|
+
const currentMst = await this.getRoot();
|
|
201
|
+
if (!currentMst) return [];
|
|
202
|
+
|
|
203
|
+
const prefix = `${collection}/`;
|
|
204
|
+
const leaves = await currentMst.listWithPrefix(prefix, limit);
|
|
205
|
+
|
|
206
|
+
return leaves
|
|
207
|
+
.filter(leaf => !cursor || leaf.key > `${collection}/${cursor}`)
|
|
208
|
+
.map(leaf => ({
|
|
209
|
+
key: leaf.key.replace(prefix, ''),
|
|
210
|
+
cid: leaf.value,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Update the repo root to point to the new MST
|
|
216
|
+
*/
|
|
217
|
+
async updateRoot(mst: MST, rev: number): Promise<void> {
|
|
218
|
+
const db = drizzle(this.env.DB);
|
|
219
|
+
const rootCid = await mst.getPointer();
|
|
220
|
+
|
|
221
|
+
await db
|
|
222
|
+
.insert(repo_root)
|
|
223
|
+
.values({
|
|
224
|
+
did: this.did,
|
|
225
|
+
commitCid: rootCid.toString(),
|
|
226
|
+
rev,
|
|
227
|
+
})
|
|
228
|
+
.onConflictDoUpdate({
|
|
229
|
+
target: repo_root.did,
|
|
230
|
+
set: {
|
|
231
|
+
commitCid: rootCid.toString(),
|
|
232
|
+
rev,
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
.run();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Extract operations from MST diff between two commits
|
|
240
|
+
* Compares old MST root with new MST root to identify create/update/delete operations
|
|
241
|
+
*/
|
|
242
|
+
async extractOps(prevRoot: CID | null, newRoot: CID): Promise<RepoOp[]> {
|
|
243
|
+
const ops: RepoOp[] = [];
|
|
244
|
+
|
|
245
|
+
// Load both trees
|
|
246
|
+
const newMst = await MST.load(this.blockstore, newRoot).getEntries();
|
|
247
|
+
const prevMst = prevRoot ? await MST.load(this.blockstore, prevRoot).getEntries() : [];
|
|
248
|
+
|
|
249
|
+
// Build maps for efficient lookup
|
|
250
|
+
const prevMap = new Map<string, CID>();
|
|
251
|
+
const newMap = new Map<string, CID>();
|
|
252
|
+
|
|
253
|
+
// Collect all leaves from previous tree
|
|
254
|
+
await this.collectLeaves(prevMst, prevMap);
|
|
255
|
+
|
|
256
|
+
// Collect all leaves from new tree
|
|
257
|
+
await this.collectLeaves(newMst, newMap);
|
|
258
|
+
|
|
259
|
+
// Find creates and updates
|
|
260
|
+
for (const [path, cid] of Array.from(newMap.entries())) {
|
|
261
|
+
const prevCid = prevMap.get(path);
|
|
262
|
+
if (!prevCid) {
|
|
263
|
+
// New key - create operation
|
|
264
|
+
ops.push({
|
|
265
|
+
action: 'create',
|
|
266
|
+
path,
|
|
267
|
+
cid,
|
|
268
|
+
});
|
|
269
|
+
} else if (!prevCid.equals(cid)) {
|
|
270
|
+
// Key exists but CID changed - update operation
|
|
271
|
+
ops.push({
|
|
272
|
+
action: 'update',
|
|
273
|
+
path,
|
|
274
|
+
cid,
|
|
275
|
+
prev: prevCid,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Find deletes
|
|
281
|
+
for (const [path, prevCid] of Array.from(prevMap.entries())) {
|
|
282
|
+
if (!newMap.has(path)) {
|
|
283
|
+
// Key no longer exists - delete operation
|
|
284
|
+
ops.push({
|
|
285
|
+
action: 'delete',
|
|
286
|
+
path,
|
|
287
|
+
cid: null,
|
|
288
|
+
prev: prevCid,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Sort ops by path for deterministic ordering
|
|
294
|
+
ops.sort((a, b) => a.path.localeCompare(b.path));
|
|
295
|
+
|
|
296
|
+
return ops;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Recursively collect all leaves from MST entries into a map
|
|
301
|
+
*/
|
|
302
|
+
private async collectLeaves(entries: (MST | Leaf)[], map: Map<string, CID>): Promise<void> {
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
if (entry.isLeaf()) {
|
|
305
|
+
map.set(entry.key, entry.value);
|
|
306
|
+
} else {
|
|
307
|
+
// Recursively collect from subtree
|
|
308
|
+
const subEntries = await entry.getEntries();
|
|
309
|
+
await this.collectLeaves(subEntries, map);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Store a record in the blockstore and return its CID
|
|
316
|
+
*/
|
|
317
|
+
private async storeRecord(record: unknown): Promise<CID> {
|
|
318
|
+
const bytes = dagCbor.encode(record);
|
|
319
|
+
const cid = await cidForCbor(record);
|
|
320
|
+
await this.blockstore.put(cid, bytes);
|
|
321
|
+
return cid;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Store all blocks from an MST to the blockstore
|
|
326
|
+
*/
|
|
327
|
+
private async storeMstBlocks(mst: MST): Promise<void> {
|
|
328
|
+
const { cid, bytes } = await mst.serialize();
|
|
329
|
+
await this.blockstore.put(cid, bytes);
|
|
330
|
+
|
|
331
|
+
// Recursively store child blocks
|
|
332
|
+
const entries = await mst.getEntries();
|
|
333
|
+
for (const entry of entries) {
|
|
334
|
+
if (entry.isTree()) {
|
|
335
|
+
await this.storeMstBlocks(entry);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|