A minimal AT Protocol Personal Data Server written in JavaScript.
atproto pds
at main 184 lines 4.9 kB view raw
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}