···11+---
22+'@atcute/car': minor
33+---
44+55+add streaming support for `CarReader` and `RepoReader`, which should allow for efficient reading of
66+CAR archives.
77+88+```ts
99+import { CarReader, RepoReader } from '@atcute/car/v4';
1010+1111+// read AT Protocol repository exports
1212+{
1313+ await using repo = RepoReader.fromStream(stream);
1414+1515+ for await (const entry of repo) {
1616+ entry;
1717+ // ^? RepoEntry { collection: 'app.bsky.feed.post', rkey: '3lprcc55bb222', ... }
1818+ }
1919+2020+ repo.missingBlocks;
2121+ // ^? []
2222+}
2323+2424+// read generic CAR archives
2525+{
2626+ await using car = CarReader.fromStream(stream);
2727+2828+ const roots = await car.roots();
2929+3030+ for await (const entry of car) {
3131+ entry;
3232+ // ^? CarEntry { cid: CidLink {}, bytes: Uint8Array {}, ... }
3333+ }
3434+}
3535+```
3636+3737+please note that the reference PDS implementation does not yet support the Sync v1.1 proposal, which
3838+would enable more efficient streaming. additionally, some PDSes may send valid but heavily
3939+out-of-order archives that could impact streaming performance.
+58-1
.changeset/silver-waves-grow.md
···22'@atcute/car': minor
33---
4455-streamed CAR reader
55+reorganized the exported functions, the new exports should be inline with other utility packages
66+from atcute.
77+88+normally this would be considered a breaking change, but because the change doesn't exactly change
99+any of the API, I've decided to turn this into a minor change.
1010+1111+to migrate, you'd need to change your imports from `@atcute/car` to `@atcute/car/v4` subpath.
1212+1313+```ts
1414+// before (v3)
1515+import { readCar, iterateAtpRepo } from '@atcute/car';
1616+1717+// read AT Protocol repository exports
1818+for (const entry of iterateAtpRepo(buffer)) {
1919+ entry;
2020+ // ^? RepoEntry { ... }
2121+}
2222+2323+// read generic CAR archives
2424+{
2525+ const car = readCar(buffer);
2626+ const header = car.header;
2727+2828+ for (const entry of car.iterate()) {
2929+ entry;
3030+ // ^? CarEntry { ... }
3131+ }
3232+}
3333+```
3434+3535+```ts
3636+// after (v4)
3737+import { CarReader, RepoReader } from '@atcute/car/v4';
3838+3939+// alternatively
4040+import * as CarReader from '@atcute/car/v4/car-reader';
4141+import * as RepoReader from '@atcute/car/v4/repo-reader';
4242+4343+// read AT Protocol repository exports
4444+{
4545+ for (const entry of RepoReader.fromUint8Array(buffer)) {
4646+ entry;
4747+ // ^? RepoEntry { ... }
4848+ }
4949+}
5050+5151+// read generic CAR archives
5252+{
5353+ const car = CarReader.fromUint8Array(buffer);
5454+ const header = car.header;
5555+5656+ // use for..of on `car` directly, `.iterate()` is deprecated
5757+ for (const entry of car) {
5858+ entry;
5959+ // ^? CarEntry { ... }
6060+ }
6161+}
6262+```
+22-7
packages/utilities/car/README.md
···66[dasl-car]: https://dasl.ing/car.html
7788```ts
99-// read through a CAR archive
1010-const { header, iterate } = readCar(buf);
99+import { CarReader, RepoReader } from '@atcute/car/v4';
1010+1111+// read AT Protocol repository exports
1212+{
1313+ await using repo = RepoReader.fromStream(stream);
1414+1515+ for await (const entry of repo) {
1616+ entry;
1717+ // ^? RepoEntry { collection: 'app.bsky.feed.post', rkey: '3lprcc55bb222', ... }
1818+ }
11191212-for (const { cid, bytes } of iterate()) {
1313- // ...
2020+ repo.missingBlocks;
2121+ // ^? []
1422}
15231616-// convenient iterator for reading through an AT Protocol CAR repository
1717-for (const { collection, rkey, record } of iterateAtpRepo(buf)) {
1818- // ...
2424+// read generic CAR archives
2525+{
2626+ await using car = CarReader.fromStream(stream);
2727+2828+ const roots = await car.roots();
2929+3030+ for await (const entry of car) {
3131+ entry;
3232+ // ^? CarEntry { cid: CidLink {}, bytes: Uint8Array {}, ... }
3333+ }
1934}
2035```
···11-import * as CBOR from '@atcute/cbor';
22-import * as CID from '@atcute/cid';
33-44-import { readCar, type CarEntry } from './reader.js';
55-66-const decoder = new TextDecoder();
77-88-export type BlockMap = Map<string, CarEntry>;
99-1010-export class RepoEntry {
1111- constructor(
1212- /** The collection this record belongs to */
1313- public readonly collection: string,
1414- /** Record key */
1515- public readonly rkey: string,
1616- /** CID of this record */
1717- public readonly cid: CID.CidLink,
1818- private blockmap: BlockMap,
1919- ) {}
2020-2121- /**
2222- * returns the associated CarEntry for this record
2323- */
2424- get carEntry(): CarEntry {
2525- const cid = this.cid.$link;
2626-2727- const entry = this.blockmap.get(cid);
2828- assert(entry != null, `cid not found in blockmap; cid=${cid}`);
2929-3030- return entry;
3131- }
3232-3333- /**
3434- * returns the raw contents of this record
3535- */
3636- get bytes(): Uint8Array {
3737- return this.carEntry.bytes;
3838- }
3939-4040- /**
4141- * returns the decoded contents of this record
4242- */
4343- get record(): unknown {
4444- return CBOR.decode(this.bytes);
4545- }
4646-}
4747-4848-export function* iterateAtpRepo(buf: Uint8Array): Generator<RepoEntry> {
4949- const { header, iterate } = readCar(buf);
5050- const roots = header.data.roots;
5151-5252- assert(roots.length === 1, `expected only 1 root in the car archive; got=${roots.length}`);
5353-5454- const blockmap = collectBlock(iterate());
5555- assert(blockmap.size > 0, `expected at least 1 block in the archive; got=${blockmap.size}`);
5656-5757- const commit = readBlock(blockmap, roots[0], isCommit);
5858- for (const { key, cid } of walkMstEntries(blockmap, commit.data)) {
5959- const [collection, rkey] = key.split('/');
6060-6161- yield new RepoEntry(collection, rkey, cid, blockmap);
6262- }
6363-}
6464-6565-/**
6666- * collects entries from a CAR archive into a mapping of CID string -> actual bytes
6767- * @param iterator a generator that yields objects with a `cid` and `bytes` property
6868- * @returns a mapping of CID string -> actual bytes
6969- */
7070-export function collectBlock(iterator: Generator<CarEntry>): BlockMap {
7171- const blockmap: BlockMap = new Map();
7272- for (const entry of iterator) {
7373- blockmap.set(CID.toString(entry.cid), entry);
7474- }
7575-7676- return blockmap;
7777-}
7878-7979-/**
8080- * reads a block from the blockmap and validates it against the provided validation function
8181- * @param map a mapping of CID string -> actual bytes
8282- * @param link a CID link to read
8383- * @param validate a validation function to validate the decoded data
8484- * @returns the decoded and validated data
8585- */
8686-export function readBlock<T>(map: BlockMap, link: CID.CidLink, validate: (value: unknown) => value is T): T {
8787- const cid = link.$link;
8888-8989- const entry = map.get(cid);
9090- assert(entry != null, `cid not found in blockmap; cid=${cid}`);
9191-9292- const data = CBOR.decode(entry.bytes);
9393- assert(validate(data), `validation failed for cid=${cid}`);
9494-9595- return data;
9696-}
9797-9898-/** node entry object */
9999-export interface NodeEntry {
100100- key: string;
101101- cid: CID.CidLink;
102102-}
103103-104104-/**
105105- * walks the entries of a Merkle Sorted Tree (MST) in a depth-first manner
106106- * @param map a mapping of CID string -> actual bytes
107107- * @param pointer a CID link to the root of the MST
108108- * @returns a generator that yields the entries of the MST
109109- */
110110-export function* walkMstEntries(map: BlockMap, pointer: CID.CidLink): Generator<NodeEntry> {
111111- const data = readBlock(map, pointer, isMstNode);
112112- const entries = data.e;
113113-114114- let lastKey = '';
115115-116116- if (data.l !== null) {
117117- yield* walkMstEntries(map, data.l);
118118- }
119119-120120- for (let i = 0, il = entries.length; i < il; i++) {
121121- const entry = entries[i];
122122-123123- const key_str = decoder.decode(CBOR.fromBytes(entry.k));
124124- const key = lastKey.slice(0, entry.p) + key_str;
125125-126126- lastKey = key;
127127-128128- yield { key: key, cid: entry.v };
129129-130130- if (entry.t !== null) {
131131- yield* walkMstEntries(map, entry.t);
132132- }
133133- }
134134-}
135135-136136-function assert(condition: boolean, message: string): asserts condition {
137137- if (!condition) {
138138- throw new Error(message);
139139- }
140140-}
141141-142142-const isCidLink = (value: unknown): value is CID.CidLink => {
143143- if (value instanceof CID.CidLinkWrapper) {
144144- return true;
145145- }
146146-147147- if (value === null || typeof value !== 'object') {
148148- return false;
149149- }
150150-151151- return '$link' in value && typeof value.$link === 'string';
152152-};
153153-154154-const isBytes = (value: unknown): value is CBOR.Bytes => {
155155- if (value instanceof CBOR.BytesWrapper) {
156156- return true;
157157- }
158158-159159- if (value === null || typeof value !== 'object') {
160160- return false;
161161- }
162162-163163- return '$bytes' in value && typeof value.$bytes === 'string';
164164-};
165165-166166-/** commit object */
167167-export interface Commit {
168168- version: 3;
169169- did: string;
170170- data: CID.CidLink;
171171- rev: string;
172172- prev: CID.CidLink | null;
173173- sig: CBOR.Bytes;
174174-}
175175-176176-/**
177177- * checks if a value is a valid commit object
178178- * @param value the value to check
179179- * @returns true if the value is a valid commit object, false otherwise
180180- */
181181-export const isCommit = (value: unknown): value is Commit => {
182182- if (value === null || typeof value !== 'object') {
183183- return false;
184184- }
185185-186186- const obj = value as Record<string, unknown>;
187187-188188- return (
189189- obj.version === 3 &&
190190- typeof obj.did === 'string' &&
191191- isCidLink(obj.data) &&
192192- typeof obj.rev === 'string' &&
193193- (obj.prev === null || isCidLink(obj.prev)) &&
194194- isBytes(obj.sig)
195195- );
196196-};
197197-198198-/** mst tree entry object */
199199-export interface TreeEntry {
200200- /** count of bytes shared with previous TreeEntry in this Node (if any) */
201201- p: number;
202202- /** remainder of key for this TreeEntry, after "prefixlen" have been removed */
203203- k: CBOR.Bytes;
204204- /** link to a sub-tree Node at a lower level which has keys sorting after this TreeEntry's key (to the "right"), but before the next TreeEntry's key in this Node (if any) */
205205- v: CID.CidLink;
206206- /** next subtree (to the right of leaf) */
207207- t: CID.CidLink | null;
208208-}
209209-210210-/**
211211- * checks if a value is a valid mst tree entry object
212212- * @param value the value to check
213213- * @returns true if the value is a valid mst tree entry object, false otherwise
214214- */
215215-export const isTreeEntry = (value: unknown): value is TreeEntry => {
216216- if (value === null || typeof value !== 'object') {
217217- return false;
218218- }
219219-220220- const obj = value as Record<string, unknown>;
221221-222222- return (
223223- typeof obj.p === 'number' && isBytes(obj.k) && isCidLink(obj.v) && (obj.t === null || isCidLink(obj.t))
224224- );
225225-};
226226-227227-/** mst node object */
228228-export interface MstNode {
229229- /** link to sub-tree Node on a lower level and with all keys sorting before keys at this node */
230230- l: CID.CidLink | null;
231231- /** ordered list of TreeEntry objects */
232232- e: TreeEntry[];
233233-}
234234-235235-/**
236236- * checks if a value is a valid mst node object
237237- * @param value the value to check
238238- * @returns true if the value is a valid mst node object, false otherwise
239239- */
240240-export const isMstNode = (value: unknown): value is MstNode => {
241241- if (value === null || typeof value !== 'object') {
242242- return false;
243243- }
244244-245245- const obj = value as Record<string, unknown>;
246246-247247- return (obj.l === null || isCidLink(obj.l)) && Array.isArray(obj.e) && obj.e.every(isTreeEntry);
248248-};
···11+export {
22+ collectBlock,
33+ isCommit,
44+ isMstNode,
55+ isTreeEntry,
66+ fromUint8Array as iterateAtpRepo,
77+ readBlock,
88+ RepoEntry,
99+ walkMstEntries,
1010+ type BlockMap,
1111+ type Commit,
1212+ type MstNode,
1313+ type NodeEntry,
1414+ type TreeEntry,
1515+} from '../v4/repo-reader/index.js';
+9
packages/utilities/car/lib/v3/reader.ts
···11+export {
22+ isCarV1Header,
33+ fromUint8Array as readCar,
44+ type CarEntry,
55+ type CarHeader,
66+ type CarV1Header,
77+ type StreamedCarReader,
88+ type SyncCarReader,
99+} from '../v4/car-reader/index.js';
+4
packages/utilities/car/lib/v4/car-reader/index.ts
···11+export * from './types.js';
22+33+export * from './stream-car-reader.js';
44+export * from './sync-car-reader.js';
+2
packages/utilities/car/lib/v4/index.ts
···11+export * as CarReader from './car-reader/index.js';
22+export * as RepoReader from './repo-reader/index.js';
···11+export * from './types.js';
22+33+export * from './mst.js';
44+export * from './sync-blockmap.js';
55+export * from './sync-repo-reader.js';
+110
packages/utilities/car/lib/v4/repo-reader/mst.ts
···11+import * as CBOR from '@atcute/cbor';
22+import * as CID from '@atcute/cid';
33+44+const isCidLink = (value: unknown): value is CID.CidLink => {
55+ if (value instanceof CID.CidLinkWrapper) {
66+ return true;
77+ }
88+99+ if (value === null || typeof value !== 'object') {
1010+ return false;
1111+ }
1212+1313+ return '$link' in value && typeof value.$link === 'string';
1414+};
1515+1616+const isBytes = (value: unknown): value is CBOR.Bytes => {
1717+ if (value instanceof CBOR.BytesWrapper) {
1818+ return true;
1919+ }
2020+2121+ if (value === null || typeof value !== 'object') {
2222+ return false;
2323+ }
2424+2525+ return '$bytes' in value && typeof value.$bytes === 'string';
2626+};
2727+2828+/** commit object */
2929+export interface Commit {
3030+ version: 3;
3131+ did: string;
3232+ data: CID.CidLink;
3333+ rev: string;
3434+ prev: CID.CidLink | null;
3535+ sig: CBOR.Bytes;
3636+}
3737+3838+/**
3939+ * checks if a value is a valid commit object
4040+ * @param value the value to check
4141+ * @returns true if the value is a valid commit object, false otherwise
4242+ */
4343+export const isCommit = (value: unknown): value is Commit => {
4444+ if (value === null || typeof value !== 'object') {
4545+ return false;
4646+ }
4747+4848+ const obj = value as Record<string, unknown>;
4949+5050+ return (
5151+ obj.version === 3 &&
5252+ typeof obj.did === 'string' &&
5353+ isCidLink(obj.data) &&
5454+ typeof obj.rev === 'string' &&
5555+ (obj.prev === null || isCidLink(obj.prev)) &&
5656+ isBytes(obj.sig)
5757+ );
5858+};
5959+6060+/** mst tree entry object */
6161+export interface TreeEntry {
6262+ /** count of bytes shared with previous TreeEntry in this Node (if any) */
6363+ p: number;
6464+ /** remainder of key for this TreeEntry, after "prefixlen" have been removed */
6565+ k: CBOR.Bytes;
6666+ /** link to a sub-tree Node at a lower level which has keys sorting after this TreeEntry's key (to the "right"), but before the next TreeEntry's key in this Node (if any) */
6767+ v: CID.CidLink;
6868+ /** next subtree (to the right of leaf) */
6969+ t: CID.CidLink | null;
7070+}
7171+7272+/**
7373+ * checks if a value is a valid mst tree entry object
7474+ * @param value the value to check
7575+ * @returns true if the value is a valid mst tree entry object, false otherwise
7676+ */
7777+export const isTreeEntry = (value: unknown): value is TreeEntry => {
7878+ if (value === null || typeof value !== 'object') {
7979+ return false;
8080+ }
8181+8282+ const obj = value as Record<string, unknown>;
8383+8484+ return (
8585+ typeof obj.p === 'number' && isBytes(obj.k) && isCidLink(obj.v) && (obj.t === null || isCidLink(obj.t))
8686+ );
8787+};
8888+8989+/** mst node object */
9090+export interface MstNode {
9191+ /** link to sub-tree Node on a lower level and with all keys sorting before keys at this node */
9292+ l: CID.CidLink | null;
9393+ /** ordered list of TreeEntry objects */
9494+ e: TreeEntry[];
9595+}
9696+9797+/**
9898+ * checks if a value is a valid mst node object
9999+ * @param value the value to check
100100+ * @returns true if the value is a valid mst node object, false otherwise
101101+ */
102102+export const isMstNode = (value: unknown): value is MstNode => {
103103+ if (value === null || typeof value !== 'object') {
104104+ return false;
105105+ }
106106+107107+ const obj = value as Record<string, unknown>;
108108+109109+ return (obj.l === null || isCidLink(obj.l)) && Array.isArray(obj.e) && obj.e.every(isTreeEntry);
110110+};
···11+import * as CBOR from '@atcute/cbor';
22+import * as CID from '@atcute/cid';
33+import { decodeUtf8From } from '@atcute/uint8array';
44+55+import * as CarReader from '../car-reader/index.js';
66+import { assert } from '../utils.js';
77+88+import { isMstNode } from './mst.js';
99+1010+export type BlockMap = Map<string, CarReader.CarEntry>;
1111+1212+/**
1313+ * collects entries from a CAR archive into a mapping of CID string -> actual bytes
1414+ * @param iterator a generator that yields objects with a `cid` and `bytes` property
1515+ * @returns a mapping of CID string -> actual bytes
1616+ */
1717+export const collectBlock = (iterator: Iterable<CarReader.CarEntry>): BlockMap => {
1818+ const blockmap: BlockMap = new Map();
1919+ for (const entry of iterator) {
2020+ blockmap.set(CID.toString(entry.cid), entry);
2121+ }
2222+2323+ return blockmap;
2424+};
2525+2626+/**
2727+ * reads a block from the blockmap and validates it against the provided validation function
2828+ * @param map a mapping of CID string -> actual bytes
2929+ * @param link a CID link to read
3030+ * @param validate a validation function to validate the decoded data
3131+ * @returns the decoded and validated data
3232+ */
3333+export const readBlock = <T>(
3434+ map: BlockMap,
3535+ link: CID.CidLink,
3636+ validate: (value: unknown) => value is T,
3737+): T => {
3838+ const cid = link.$link;
3939+4040+ const entry = map.get(cid);
4141+ assert(entry != null, `cid not found in blockmap; cid=${cid}`);
4242+4343+ const data = CBOR.decode(entry.bytes);
4444+ assert(validate(data), `validation failed for cid=${cid}`);
4545+4646+ return data;
4747+};
4848+4949+/** node entry object */
5050+export interface NodeEntry {
5151+ key: string;
5252+ cid: CID.CidLink;
5353+}
5454+5555+/**
5656+ * walks the entries of a Merkle Sorted Tree (MST) in a depth-first manner
5757+ * @param map a mapping of CID string -> actual bytes
5858+ * @param pointer a CID link to the root of the MST
5959+ * @returns a generator that yields the entries of the MST
6060+ */
6161+export function* walkMstEntries(map: BlockMap, pointer: CID.CidLink): Generator<NodeEntry> {
6262+ const data = readBlock(map, pointer, isMstNode);
6363+ const entries = data.e;
6464+6565+ let lastKey = '';
6666+6767+ if (data.l !== null) {
6868+ yield* walkMstEntries(map, data.l);
6969+ }
7070+7171+ for (let i = 0, il = entries.length; i < il; i++) {
7272+ const entry = entries[i];
7373+7474+ const key_str = decodeUtf8From(CBOR.fromBytes(entry.k));
7575+ const key = lastKey.slice(0, entry.p) + key_str;
7676+7777+ lastKey = key;
7878+7979+ yield { key: key, cid: entry.v };
8080+8181+ if (entry.t !== null) {
8282+ yield* walkMstEntries(map, entry.t);
8383+ }
8484+ }
8585+}
···11+import * as CBOR from '@atcute/cbor';
22+import * as CID from '@atcute/cid';
33+44+import { type CarEntry } from '../car-reader/index.js';
55+66+export class RepoEntry {
77+ /** @internal */
88+ constructor(
99+ /** the collection this record belongs to */
1010+ public readonly collection: string,
1111+ /** record key */
1212+ public readonly rkey: string,
1313+ /** CID of this record */
1414+ public readonly cid: CID.CidLink,
1515+ /** the associated CarEntry for this record */
1616+ public readonly carEntry: CarEntry,
1717+ ) {}
1818+1919+ /**
2020+ * raw contents of this record
2121+ */
2222+ get bytes(): Uint8Array {
2323+ return this.carEntry.bytes;
2424+ }
2525+2626+ /**
2727+ * decoded contents of this record
2828+ */
2929+ get record(): unknown {
3030+ return CBOR.decode(this.bytes);
3131+ }
3232+}