A minimal AT Protocol Personal Data Server written in JavaScript.
atproto
pds
1// @pds/core/loader - Repository loader from CAR files
2// Loads AT Protocol repositories from CAR archives into storage
3
4import { parseCarFile, getCarHeader } from './car.js';
5import { cborDecode, cidToString } from './repo.js';
6import { walkMst } from './mst.js';
7
8/**
9 * @typedef {import('./ports.js').ActorStoragePort} ActorStoragePort
10 */
11
12/**
13 * @typedef {Object} CommitData
14 * @property {string} did - Repository DID
15 * @property {number} version - Commit version (usually 3)
16 * @property {string} rev - Revision string (TID)
17 * @property {string|null} prev - Previous commit CID
18 * @property {string|null} data - MST root CID
19 * @property {Uint8Array} [sig] - Signature bytes
20 */
21
22/**
23 * @typedef {Object} LoadResult
24 * @property {string} did - Repository DID
25 * @property {string} commitCid - Root commit CID
26 * @property {string} rev - Revision string
27 * @property {number} recordCount - Number of records loaded
28 * @property {number} blockCount - Number of blocks loaded
29 */
30
31/**
32 * Load a repository from CAR bytes into storage
33 * @param {Uint8Array} carBytes - CAR file bytes
34 * @param {ActorStoragePort} actorStorage - Storage to populate
35 * @returns {Promise<LoadResult>}
36 */
37export async function loadRepositoryFromCar(carBytes, actorStorage) {
38 // Parse the CAR file
39 const { roots, blocks } = parseCarFile(carBytes);
40
41 if (roots.length === 0) {
42 throw new Error('CAR file has no roots');
43 }
44
45 const commitCid = roots[0];
46 const commitBytes = blocks.get(commitCid);
47
48 if (!commitBytes) {
49 throw new Error(`Commit block not found: ${commitCid}`);
50 }
51
52 // Decode commit
53 const commit = cborDecode(commitBytes);
54 const did = commit.did;
55 const rev = commit.rev;
56 const version = commit.version;
57
58 if (!did || typeof did !== 'string') {
59 throw new Error('Invalid commit: missing DID');
60 }
61
62 if (version !== 3 && version !== 2) {
63 throw new Error(`Unsupported commit version: ${version}`);
64 }
65
66 // Get MST root CID
67 const mstRootCid = commit.data ? cidToString(commit.data) : null;
68
69 // Store all blocks first
70 for (const [cid, data] of blocks) {
71 await actorStorage.putBlock(cid, data);
72 }
73
74 // Set metadata
75 await actorStorage.setDid(did);
76
77 // Walk MST and extract records
78 let recordCount = 0;
79 if (mstRootCid) {
80 /**
81 * @param {string} cid
82 * @returns {Promise<Uint8Array|null>}
83 */
84 const getBlock = async (cid) => blocks.get(cid) || null;
85
86 for await (const { key, cid } of walkMst(mstRootCid, getBlock)) {
87 // key format: "collection/rkey"
88 const slashIndex = key.indexOf('/');
89 if (slashIndex === -1) {
90 console.warn(`Invalid record key format: ${key}`);
91 continue;
92 }
93
94 const collection = key.slice(0, slashIndex);
95 const rkey = key.slice(slashIndex + 1);
96 const uri = `at://${did}/${collection}/${rkey}`;
97
98 // Get record data
99 const recordBytes = blocks.get(cid);
100 if (!recordBytes) {
101 console.warn(`Record block not found: ${cid}`);
102 continue;
103 }
104
105 // Store record
106 await actorStorage.putRecord(uri, cid, collection, rkey, recordBytes);
107 recordCount++;
108 }
109 }
110
111 // Store commit
112 const prevCommit = await actorStorage.getLatestCommit();
113 const seq = prevCommit ? prevCommit.seq + 1 : 1;
114 await actorStorage.putCommit(seq, commitCid, rev, commit.prev ? cidToString(commit.prev) : null);
115
116 return {
117 did,
118 commitCid,
119 rev,
120 recordCount,
121 blockCount: blocks.size,
122 };
123}
124
125/**
126 * Get repository info from CAR without loading into storage
127 * @param {Uint8Array} carBytes - CAR file bytes
128 * @returns {{ did: string, commitCid: string, rev: string }}
129 */
130export function getCarRepoInfo(carBytes) {
131 const { roots, blocks } = parseCarFile(carBytes);
132
133 if (roots.length === 0) {
134 throw new Error('CAR file has no roots');
135 }
136
137 const commitCid = roots[0];
138 const commitBytes = blocks.get(commitCid);
139
140 if (!commitBytes) {
141 throw new Error(`Commit block not found: ${commitCid}`);
142 }
143
144 const commit = cborDecode(commitBytes);
145
146 return {
147 did: commit.did,
148 commitCid,
149 rev: commit.rev,
150 };
151}
152
153/**
154 * Validate CAR file structure without fully loading
155 * @param {Uint8Array} carBytes - CAR file bytes
156 * @returns {{ valid: boolean, error?: string, did?: string }}
157 */
158export function validateCarFile(carBytes) {
159 try {
160 const { version, roots } = getCarHeader(carBytes);
161
162 if (version !== 1) {
163 return { valid: false, error: `Unsupported CAR version: ${version}` };
164 }
165
166 if (roots.length === 0) {
167 return { valid: false, error: 'CAR file has no roots' };
168 }
169
170 // Quick parse to verify commit
171 const info = getCarRepoInfo(carBytes);
172
173 if (!info.did) {
174 return { valid: false, error: 'Missing DID in commit' };
175 }
176
177 return { valid: true, did: info.did };
178 } catch (err) {
179 return {
180 valid: false,
181 error: err instanceof Error ? err.message : String(err),
182 };
183 }
184}