@hatk/hatk 0.0.1-alpha.9 → 0.0.1-alpha.90

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.
Files changed (218) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +113 -0
  4. package/dist/backfill.d.ts +29 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +137 -51
  7. package/dist/car.d.ts +20 -0
  8. package/dist/car.d.ts.map +1 -1
  9. package/dist/car.js +108 -0
  10. package/dist/cli.js +247 -1043
  11. package/dist/config.d.ts +145 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +61 -8
  14. package/dist/database/adapter-factory.d.ts +6 -0
  15. package/dist/database/adapter-factory.d.ts.map +1 -0
  16. package/dist/database/adapter-factory.js +20 -0
  17. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  18. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  19. package/dist/database/adapters/duckdb-search.js +27 -0
  20. package/dist/database/adapters/duckdb.d.ts +25 -0
  21. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  22. package/dist/database/adapters/duckdb.js +161 -0
  23. package/dist/database/adapters/sqlite-search.d.ts +16 -0
  24. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  25. package/dist/database/adapters/sqlite-search.js +76 -0
  26. package/dist/database/adapters/sqlite.d.ts +18 -0
  27. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  28. package/dist/database/adapters/sqlite.js +88 -0
  29. package/dist/{db.d.ts → database/db.d.ts} +68 -6
  30. package/dist/database/db.d.ts.map +1 -0
  31. package/dist/database/db.js +1775 -0
  32. package/dist/database/dialect.d.ts +45 -0
  33. package/dist/database/dialect.d.ts.map +1 -0
  34. package/dist/database/dialect.js +72 -0
  35. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  36. package/dist/database/fts.d.ts.map +1 -0
  37. package/dist/{fts.js → database/fts.js} +122 -34
  38. package/dist/database/index.d.ts +7 -0
  39. package/dist/database/index.d.ts.map +1 -0
  40. package/dist/database/index.js +6 -0
  41. package/dist/database/ports.d.ts +50 -0
  42. package/dist/database/ports.d.ts.map +1 -0
  43. package/dist/database/ports.js +1 -0
  44. package/dist/{schema.d.ts → database/schema.d.ts} +16 -3
  45. package/dist/database/schema.d.ts.map +1 -0
  46. package/dist/{schema.js → database/schema.js} +126 -44
  47. package/dist/dev-entry.d.ts +8 -0
  48. package/dist/dev-entry.d.ts.map +1 -0
  49. package/dist/dev-entry.js +127 -0
  50. package/dist/feeds.d.ts +26 -8
  51. package/dist/feeds.d.ts.map +1 -1
  52. package/dist/feeds.js +72 -16
  53. package/dist/hooks.d.ts +102 -0
  54. package/dist/hooks.d.ts.map +1 -0
  55. package/dist/hooks.js +186 -0
  56. package/dist/hydrate.d.ts +49 -6
  57. package/dist/hydrate.d.ts.map +1 -1
  58. package/dist/hydrate.js +21 -20
  59. package/dist/indexer.d.ts +156 -3
  60. package/dist/indexer.d.ts.map +1 -1
  61. package/dist/indexer.js +539 -94
  62. package/dist/jetstream.d.ts +118 -0
  63. package/dist/jetstream.d.ts.map +1 -0
  64. package/dist/jetstream.js +225 -0
  65. package/dist/labels.d.ts +36 -0
  66. package/dist/labels.d.ts.map +1 -1
  67. package/dist/labels.js +71 -6
  68. package/dist/lex-types.d.ts +2 -0
  69. package/dist/lex-types.d.ts.map +1 -1
  70. package/dist/lexicon-resolve.d.ts.map +1 -1
  71. package/dist/lexicon-resolve.js +27 -112
  72. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  73. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  74. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  75. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  76. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  77. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  78. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  79. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  80. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  81. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  82. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  83. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  84. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  85. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  86. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  87. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  88. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  89. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  90. package/dist/logger.d.ts +29 -0
  91. package/dist/logger.d.ts.map +1 -1
  92. package/dist/logger.js +29 -0
  93. package/dist/main.js +198 -75
  94. package/dist/mst.d.ts +15 -0
  95. package/dist/mst.d.ts.map +1 -1
  96. package/dist/mst.js +13 -0
  97. package/dist/oauth/db.d.ts +16 -1
  98. package/dist/oauth/db.d.ts.map +1 -1
  99. package/dist/oauth/db.js +74 -19
  100. package/dist/oauth/discovery.d.ts +10 -0
  101. package/dist/oauth/discovery.d.ts.map +1 -1
  102. package/dist/oauth/discovery.js +54 -2
  103. package/dist/oauth/server.d.ts +93 -1
  104. package/dist/oauth/server.d.ts.map +1 -1
  105. package/dist/oauth/server.js +416 -39
  106. package/dist/oauth/service-describe.d.ts +42 -0
  107. package/dist/oauth/service-describe.d.ts.map +1 -0
  108. package/dist/oauth/service-describe.js +108 -0
  109. package/dist/oauth/session.d.ts +32 -0
  110. package/dist/oauth/session.d.ts.map +1 -0
  111. package/dist/oauth/session.js +131 -0
  112. package/dist/opengraph.d.ts +10 -0
  113. package/dist/opengraph.d.ts.map +1 -1
  114. package/dist/opengraph.js +94 -42
  115. package/dist/pds-proxy.d.ts +82 -0
  116. package/dist/pds-proxy.d.ts.map +1 -0
  117. package/dist/pds-proxy.js +356 -0
  118. package/dist/private-collections.d.ts +13 -0
  119. package/dist/private-collections.d.ts.map +1 -0
  120. package/dist/private-collections.js +27 -0
  121. package/dist/push.d.ts +54 -0
  122. package/dist/push.d.ts.map +1 -0
  123. package/dist/push.js +367 -0
  124. package/dist/renderer.d.ts +27 -0
  125. package/dist/renderer.d.ts.map +1 -0
  126. package/dist/renderer.js +46 -0
  127. package/dist/resolve-hatk.d.ts +6 -0
  128. package/dist/resolve-hatk.d.ts.map +1 -0
  129. package/dist/resolve-hatk.js +20 -0
  130. package/dist/response.d.ts +16 -0
  131. package/dist/response.d.ts.map +1 -0
  132. package/dist/response.js +69 -0
  133. package/dist/scanner.d.ts +21 -0
  134. package/dist/scanner.d.ts.map +1 -0
  135. package/dist/scanner.js +88 -0
  136. package/dist/seed.d.ts +19 -0
  137. package/dist/seed.d.ts.map +1 -1
  138. package/dist/seed.js +43 -4
  139. package/dist/server-init.d.ts +8 -0
  140. package/dist/server-init.d.ts.map +1 -0
  141. package/dist/server-init.js +62 -0
  142. package/dist/server.d.ts +34 -3
  143. package/dist/server.d.ts.map +1 -1
  144. package/dist/server.js +1021 -649
  145. package/dist/setup.d.ts +28 -1
  146. package/dist/setup.d.ts.map +1 -1
  147. package/dist/setup.js +50 -3
  148. package/dist/spaces/blob.d.ts +51 -0
  149. package/dist/spaces/blob.d.ts.map +1 -0
  150. package/dist/spaces/blob.js +111 -0
  151. package/dist/spaces/credential.d.ts +62 -0
  152. package/dist/spaces/credential.d.ts.map +1 -0
  153. package/dist/spaces/credential.js +184 -0
  154. package/dist/spaces/engine.d.ts +97 -0
  155. package/dist/spaces/engine.d.ts.map +1 -0
  156. package/dist/spaces/engine.js +559 -0
  157. package/dist/spaces/guard.d.ts +41 -0
  158. package/dist/spaces/guard.d.ts.map +1 -0
  159. package/dist/spaces/guard.js +79 -0
  160. package/dist/spaces/identity.d.ts +37 -0
  161. package/dist/spaces/identity.d.ts.map +1 -0
  162. package/dist/spaces/identity.js +88 -0
  163. package/dist/spaces/index.d.ts +55 -0
  164. package/dist/spaces/index.d.ts.map +1 -0
  165. package/dist/spaces/index.js +133 -0
  166. package/dist/spaces/notify.d.ts +51 -0
  167. package/dist/spaces/notify.d.ts.map +1 -0
  168. package/dist/spaces/notify.js +121 -0
  169. package/dist/spaces/records.d.ts +37 -0
  170. package/dist/spaces/records.d.ts.map +1 -0
  171. package/dist/spaces/records.js +63 -0
  172. package/dist/spaces/store.d.ts +51 -0
  173. package/dist/spaces/store.d.ts.map +1 -0
  174. package/dist/spaces/store.js +99 -0
  175. package/dist/spaces/uri.d.ts +60 -0
  176. package/dist/spaces/uri.d.ts.map +1 -0
  177. package/dist/spaces/uri.js +97 -0
  178. package/dist/spaces/verify.d.ts +39 -0
  179. package/dist/spaces/verify.d.ts.map +1 -0
  180. package/dist/spaces/verify.js +87 -0
  181. package/dist/spaces/viewer.d.ts +51 -0
  182. package/dist/spaces/viewer.d.ts.map +1 -0
  183. package/dist/spaces/viewer.js +167 -0
  184. package/dist/spaces/visibility.d.ts +57 -0
  185. package/dist/spaces/visibility.d.ts.map +1 -0
  186. package/dist/spaces/visibility.js +81 -0
  187. package/dist/templates/feed.tpl +18 -0
  188. package/dist/templates/hook.tpl +5 -0
  189. package/dist/templates/label.tpl +15 -0
  190. package/dist/templates/og.tpl +17 -0
  191. package/dist/templates/seed.tpl +11 -0
  192. package/dist/templates/setup.tpl +5 -0
  193. package/dist/templates/test-feed.tpl +19 -0
  194. package/dist/templates/test-xrpc.tpl +19 -0
  195. package/dist/templates/xrpc.tpl +53 -0
  196. package/dist/test.d.ts +3 -1
  197. package/dist/test.d.ts.map +1 -1
  198. package/dist/test.js +60 -36
  199. package/dist/views.js +1 -1
  200. package/dist/vite-plugin.d.ts +1 -1
  201. package/dist/vite-plugin.d.ts.map +1 -1
  202. package/dist/vite-plugin.js +254 -66
  203. package/dist/xrpc.d.ts +101 -12
  204. package/dist/xrpc.d.ts.map +1 -1
  205. package/dist/xrpc.js +255 -55
  206. package/package.json +21 -9
  207. package/public/admin-auth.js +271 -306
  208. package/public/admin.html +177 -61
  209. package/dist/db.d.ts.map +0 -1
  210. package/dist/db.js +0 -1361
  211. package/dist/fts.d.ts.map +0 -1
  212. package/dist/oauth/hooks.d.ts +0 -10
  213. package/dist/oauth/hooks.d.ts.map +0 -1
  214. package/dist/oauth/hooks.js +0 -40
  215. package/dist/schema.d.ts.map +0 -1
  216. package/dist/test-browser.d.ts +0 -14
  217. package/dist/test-browser.d.ts.map +0 -1
  218. package/dist/test-browser.js +0 -26
@@ -0,0 +1,19 @@
1
+ import { type IncomingMessage, type ServerResponse } from 'node:http';
2
+ /**
3
+ * Convert a Node.js IncomingMessage to a Web Standard Request.
4
+ */
5
+ export declare function toRequest(req: IncomingMessage, base: string): Request;
6
+ /**
7
+ * Pipe a Web Standard Response back to a Node.js ServerResponse.
8
+ */
9
+ export declare function sendResponse(res: ServerResponse, response: Response): Promise<void>;
10
+ /** Routes handled by hatk — everything else can fall through to a framework handler. */
11
+ export declare const HATK_ROUTES: string[];
12
+ export declare function isHatkRoute(pathname: string): boolean;
13
+ /**
14
+ * Create a Node.js HTTP server from a Web Standard fetch handler.
15
+ * If a fallback Node middleware is provided, non-hatk routes are sent to it
16
+ * (e.g. SvelteKit's handler from build/handler.js).
17
+ */
18
+ export declare function serve(handler: (request: Request) => Promise<Response>, port: number, base?: string, fallback?: (req: IncomingMessage, res: ServerResponse, next: () => void) => void): import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
19
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,cAAc,EAAgB,MAAM,WAAW,CAAA;AAEnF;;GAEG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CA0BrE;AAED;;GAEG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,cAAc,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBzF;AAED,wFAAwF;AACxF,eAAO,MAAM,WAAW,UAmBvB,CAAA;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAErD;AAED;;;;GAIG;AACH,wBAAgB,KAAK,CACnB,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,EAChD,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE,MAAM,EACb,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,IAAI,KAAK,IAAI,6EA4BjF"}
@@ -0,0 +1,113 @@
1
+ import { createServer } from 'node:http';
2
+ /**
3
+ * Convert a Node.js IncomingMessage to a Web Standard Request.
4
+ */
5
+ export function toRequest(req, base) {
6
+ const url = new URL(req.url, base);
7
+ const headers = new Headers();
8
+ for (const [key, value] of Object.entries(req.headers)) {
9
+ if (value) {
10
+ if (Array.isArray(value)) {
11
+ for (const v of value)
12
+ headers.append(key, v);
13
+ }
14
+ else {
15
+ headers.set(key, value);
16
+ }
17
+ }
18
+ }
19
+ const init = {
20
+ method: req.method,
21
+ headers,
22
+ };
23
+ // GET and HEAD requests cannot have a body
24
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
25
+ // @ts-expect-error — Node.js streams are valid body sources
26
+ init.body = req;
27
+ init.duplex = 'half';
28
+ }
29
+ return new Request(url.href, init);
30
+ }
31
+ /**
32
+ * Pipe a Web Standard Response back to a Node.js ServerResponse.
33
+ */
34
+ export async function sendResponse(res, response) {
35
+ const rawHeaders = [];
36
+ response.headers.forEach((value, name) => {
37
+ rawHeaders.push(name, value);
38
+ });
39
+ res.writeHead(response.status, rawHeaders);
40
+ if (!response.body) {
41
+ res.end();
42
+ return;
43
+ }
44
+ const reader = response.body.getReader();
45
+ try {
46
+ while (true) {
47
+ const { done, value } = await reader.read();
48
+ if (done)
49
+ break;
50
+ res.write(value);
51
+ }
52
+ }
53
+ finally {
54
+ reader.releaseLock();
55
+ res.end();
56
+ }
57
+ }
58
+ /** Routes handled by hatk — everything else can fall through to a framework handler. */
59
+ export const HATK_ROUTES = [
60
+ '/xrpc/',
61
+ '/blob/', // dev image proxy — see blobUrl in xrpc.ts
62
+ '/space-blob', // a blob inside a permissioned space, served per viewer
63
+ '/.well-known/did.json', // this instance's own DID, for space notice delivery
64
+ '/oauth/',
65
+ '/oauth-client-metadata.json',
66
+ '/.well-known/oauth-authorization-server',
67
+ '/.well-known/oauth-protected-resource',
68
+ '/og/',
69
+ '/admin',
70
+ '/repos',
71
+ '/info/',
72
+ '/_health',
73
+ '/robots.txt',
74
+ '/auth/logout',
75
+ '/auth/switch',
76
+ '/auth/accounts',
77
+ '/__dev/',
78
+ ];
79
+ export function isHatkRoute(pathname) {
80
+ return HATK_ROUTES.some((r) => pathname.startsWith(r) || pathname === r);
81
+ }
82
+ /**
83
+ * Create a Node.js HTTP server from a Web Standard fetch handler.
84
+ * If a fallback Node middleware is provided, non-hatk routes are sent to it
85
+ * (e.g. SvelteKit's handler from build/handler.js).
86
+ */
87
+ export function serve(handler, port, base, fallback) {
88
+ const origin = base || `http://localhost:${port}`;
89
+ const server = createServer(async (req, res) => {
90
+ try {
91
+ const url = new URL(req.url, origin);
92
+ // If we have a fallback (e.g. SvelteKit) and this isn't a hatk route, skip hatk
93
+ if (fallback && !isHatkRoute(url.pathname)) {
94
+ fallback(req, res, () => {
95
+ res.writeHead(404);
96
+ res.end('Not found');
97
+ });
98
+ return;
99
+ }
100
+ const request = toRequest(req, origin);
101
+ const response = await handler(request);
102
+ await sendResponse(res, response);
103
+ }
104
+ catch (err) {
105
+ if (!res.headersSent) {
106
+ res.writeHead(500, { 'Content-Type': 'application/json' });
107
+ }
108
+ res.end(JSON.stringify({ error: err.message }));
109
+ }
110
+ });
111
+ server.listen(port);
112
+ return server;
113
+ }
@@ -1,15 +1,42 @@
1
+ /**
2
+ * The collections whose local rows a full re-import may purge before
3
+ * re-inserting from the repo CAR.
4
+ *
5
+ * Private collections are AppView-authoritative and never present in a repo:
6
+ * purging their rows would destroy data the re-import can never restore (this
7
+ * happened — a login-triggered backfill wiped a deployment's activity rows).
8
+ * Exported for tests.
9
+ */
10
+ export declare function purgeableCollections(collections: Iterable<string>): string[];
11
+ /**
12
+ * Whether a repo with this stored status should be queued for backfill.
13
+ *
14
+ * Takendown repos are excluded: re-backfilling one ends in
15
+ * `setRepoStatus(did, 'active')`, which would silently reinstate an account an
16
+ * admin took down — takedowns must survive every redeploy. Reinstatement goes
17
+ * through `/admin/reverse-takedown` only. Exported for tests.
18
+ */
19
+ export declare function backfillEligible(status: string | null): boolean;
1
20
  import type { BackfillConfig } from './config.ts';
2
21
  /** Options passed to {@link runBackfill}. */
3
22
  interface BackfillOpts {
4
23
  /** Base URL of the relay or PDS to enumerate repos from (e.g. `wss://bsky.network`). */
5
24
  pdsUrl: string;
25
+ /** Further relays/PDSes to enumerate — the HTTP side of `config.relays`. */
26
+ extraPdsUrls?: string[];
6
27
  /** PLC directory URL used to resolve `did:plc` identifiers (e.g. `https://plc.directory`). */
7
28
  plcUrl: string;
8
29
  /** AT Protocol collection NSIDs to index (e.g. `app.bsky.feed.post`). */
9
30
  collections: Set<string>;
10
- /** Backfill behavior settings from `config.yaml`. */
31
+ /** Backfill behavior settings from `hatk.config.ts`. */
11
32
  config: BackfillConfig;
12
33
  }
34
+ /** Point DID resolution at a PLC directory. `runBackfill` does this too; call it
35
+ * at boot so resolvers that run before the first backfill have somewhere to go. */
36
+ export declare function configurePlc(url: string): void;
37
+ /** A repo's PDS endpoint, cached for the process. For serving blobs, where a
38
+ * stale answer after a migration costs one broken image, not a wrong row. */
39
+ export declare function pdsFor(did: string): Promise<string>;
13
40
  /**
14
41
  * Downloads and indexes a single user's repo via `com.atproto.sync.getRepo`.
15
42
  *
@@ -65,6 +92,6 @@ export declare function backfillRepo(did: string, collections: Set<string>, fetc
65
92
  * })
66
93
  * ```
67
94
  */
68
- export declare function runBackfill(opts: BackfillOpts): Promise<void>;
95
+ export declare function runBackfill(opts: BackfillOpts): Promise<number>;
69
96
  export {};
70
97
  //# sourceMappingURL=backfill.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"backfill.d.ts","sourceRoot":"","sources":["../src/backfill.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAEjD,6CAA6C;AAC7C,UAAU,YAAY;IACpB,wFAAwF;IACxF,MAAM,EAAE,MAAM,CAAA;IACd,8FAA8F;IAC9F,MAAM,EAAE,MAAM,CAAA;IACd,yEAAyE;IACzE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB,qDAAqD;IACrD,MAAM,EAAE,cAAc,CAAA;CACvB;AAuGD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA4H/G;AAgCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAiInE"}
1
+ {"version":3,"file":"backfill.d.ts","sourceRoot":"","sources":["../src/backfill.ts"],"names":[],"mappings":"AAkBA;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAE5E;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAE/D;AAGD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAIjD,6CAA6C;AAC7C,UAAU,YAAY;IACpB,wFAAwF;IACxF,MAAM,EAAE,MAAM,CAAA;IACd,4EAA4E;IAC5E,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB,8FAA8F;IAC9F,MAAM,EAAE,MAAM,CAAA;IACd,yEAAyE;IACzE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB,wDAAwD;IACxD,MAAM,EAAE,cAAc,CAAA;CACvB;AAWD;oFACoF;AACpF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAE9C;AAID;8EAC8E;AAC9E,wBAAsB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAMzD;AAsFD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA0K/G;AA8BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAsIrE"}
package/dist/backfill.js CHANGED
@@ -1,16 +1,59 @@
1
- import { parseCarFrame } from "./car.js";
1
+ import { noteReferencedRepos } from "./indexer.js";
2
+ import { parseCarStream } from "./car.js";
2
3
  import { cborDecode } from "./cbor.js";
3
4
  import { walkMst } from "./mst.js";
4
- import { setRepoStatus, getRepoStatus, getRepoRetryInfo, listRetryEligibleRepos, listPendingRepos, querySQL, runSQL, getSchema, bulkInsertRecords, } from "./db.js";
5
+ import { setRepoStatus, getRepoStatus, getRepoRev, getRepoRetryInfo, listRetryEligibleRepos, listPendingRepos, querySQL, runSQL, getSchema, bulkInsertRecords, } from "./database/db.js";
6
+ import { isPrivateCollection } from "./private-collections.js";
7
+ /**
8
+ * The collections whose local rows a full re-import may purge before
9
+ * re-inserting from the repo CAR.
10
+ *
11
+ * Private collections are AppView-authoritative and never present in a repo:
12
+ * purging their rows would destroy data the re-import can never restore (this
13
+ * happened — a login-triggered backfill wiped a deployment's activity rows).
14
+ * Exported for tests.
15
+ */
16
+ export function purgeableCollections(collections) {
17
+ return [...collections].filter((c) => !isPrivateCollection(c));
18
+ }
19
+ /**
20
+ * Whether a repo with this stored status should be queued for backfill.
21
+ *
22
+ * Takendown repos are excluded: re-backfilling one ends in
23
+ * `setRepoStatus(did, 'active')`, which would silently reinstate an account an
24
+ * admin took down — takedowns must survive every redeploy. Reinstatement goes
25
+ * through `/admin/reverse-takedown` only. Exported for tests.
26
+ */
27
+ export function backfillEligible(status) {
28
+ return status !== 'active' && status !== 'takendown';
29
+ }
5
30
  import { emit, timer } from "./logger.js";
6
- /** In-memory cache of DID → PDS resolution results to avoid redundant lookups. */
7
- const pdsCache = new Map();
31
+ import { validateRecord } from '@bigmoves/lexicon';
32
+ import { getLexiconArray } from "./database/schema.js";
8
33
  let plcUrl;
34
+ /** Point DID resolution at a PLC directory. `runBackfill` does this too; call it
35
+ * at boot so resolvers that run before the first backfill have somewhere to go. */
36
+ export function configurePlc(url) {
37
+ plcUrl = url;
38
+ }
39
+ const pdsCache = new Map();
40
+ /** A repo's PDS endpoint, cached for the process. For serving blobs, where a
41
+ * stale answer after a migration costs one broken image, not a wrong row. */
42
+ export async function pdsFor(did) {
43
+ const cached = pdsCache.get(did);
44
+ if (cached)
45
+ return cached;
46
+ const { pds } = await resolvePds(did);
47
+ pdsCache.set(did, pds);
48
+ return pds;
49
+ }
9
50
  /**
10
51
  * Resolves a DID to its PDS endpoint and handle by fetching the DID document.
11
52
  *
12
53
  * Supports both `did:web` (fetches `/.well-known/did.json`) and `did:plc`
13
- * (fetches from the PLC directory). Results are cached for the lifetime of the process.
54
+ * (fetches from the PLC directory). Always fetches fresh — DID docs change
55
+ * (handle renames, PDS migrations) and a stale cache silently rewrites stale
56
+ * handles back into `_repos` on every backfill.
14
57
  *
15
58
  * @example
16
59
  * ```ts
@@ -20,9 +63,6 @@ let plcUrl;
20
63
  * ```
21
64
  */
22
65
  async function resolvePds(did) {
23
- const cached = pdsCache.get(did);
24
- if (cached)
25
- return cached;
26
66
  let didDoc;
27
67
  if (did.startsWith('did:web:')) {
28
68
  const domain = did.slice('did:web:'.length);
@@ -40,12 +80,10 @@ async function resolvePds(did) {
40
80
  const pds = didDoc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint;
41
81
  if (!pds)
42
82
  throw new Error(`No PDS endpoint in DID document for ${did}`);
43
- // Extract handle from alsoKnownAs (format: "at://handle")
83
+ // First at:// entry in alsoKnownAs is the canonical handle (per @atproto/identity convention)
44
84
  const aka = didDoc.alsoKnownAs?.find((u) => u.startsWith('at://'));
45
85
  const handle = aka ? aka.slice('at://'.length) : null;
46
- const result = { pds, handle };
47
- pdsCache.set(did, result);
48
- return result;
86
+ return { pds, handle };
49
87
  }
50
88
  /**
51
89
  * Paginates through all active repos on a relay/PDS using `com.atproto.sync.listRepos`.
@@ -121,6 +159,13 @@ async function* listReposByCollection(pdsUrl, collection) {
121
159
  * ```
122
160
  */
123
161
  export async function backfillRepo(did, collections, fetchTimeout) {
162
+ // A takendown repo must never be re-imported: the success path ends in
163
+ // setRepoStatus('active'), which would reinstate the account. Guarding here
164
+ // covers every caller (boot scan, auto-backfill, admin re-index).
165
+ if ((await getRepoStatus(did)) === 'takendown') {
166
+ emit('backfill', 'takedown_skip', { did });
167
+ return 0;
168
+ }
124
169
  const elapsed = timer();
125
170
  let count = 0;
126
171
  let carSizeBytes;
@@ -128,6 +173,7 @@ export async function backfillRepo(did, collections, fetchTimeout) {
128
173
  let error;
129
174
  let resolvedPds;
130
175
  let resolvedHandle = null;
176
+ let resolvedSince = null;
131
177
  let retryCount;
132
178
  let retryAfter;
133
179
  const controller = new AbortController();
@@ -137,45 +183,70 @@ export async function backfillRepo(did, collections, fetchTimeout) {
137
183
  resolvedPds = pdsUrl;
138
184
  resolvedHandle = handle;
139
185
  timeout = setTimeout(() => controller.abort(), fetchTimeout * 1000);
140
- const res = await fetch(`${resolvedPds}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`, {
141
- signal: controller.signal,
142
- });
186
+ let lastRev = await getRepoRev(did);
187
+ const baseUrl = `${resolvedPds}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`;
188
+ let repoUrl = lastRev ? `${baseUrl}&since=${encodeURIComponent(lastRev)}` : baseUrl;
189
+ let res = await fetch(repoUrl, { signal: controller.signal });
190
+ // If the PDS rejected our `since` rev (compacted history), fall back to full import
191
+ if (res.status === 400 && lastRev) {
192
+ lastRev = null;
193
+ res = await fetch(baseUrl, { signal: controller.signal });
194
+ }
143
195
  if (!res.ok) {
144
196
  const httpErr = new Error(`getRepo failed for ${did}: ${res.status}`);
145
197
  httpErr.httpStatus = res.status;
146
198
  throw httpErr;
147
199
  }
148
- const carBytes = new Uint8Array(await res.arrayBuffer());
149
- carSizeBytes = carBytes.length;
150
- let { roots, blocks } = parseCarFrame(carBytes);
151
- // Decode commit to get MST root
152
- const rootData = blocks.get(roots[0]);
200
+ resolvedSince = lastRev;
201
+ let { roots, blocks, byteLength } = await parseCarStream(res.body);
202
+ carSizeBytes = byteLength;
203
+ // Decode commit to get MST root — if the diff CAR is missing the root block,
204
+ // fall back to a full import (the PDS compacted past our `since` rev)
205
+ let rootData = blocks.get(roots[0]);
206
+ if (!rootData && lastRev) {
207
+ lastRev = null;
208
+ resolvedSince = null;
209
+ res = await fetch(baseUrl, { signal: controller.signal });
210
+ if (!res.ok) {
211
+ const httpErr = new Error(`getRepo failed for ${did}: ${res.status}`);
212
+ httpErr.httpStatus = res.status;
213
+ throw httpErr;
214
+ }
215
+ ;
216
+ ({ roots, blocks, byteLength } = await parseCarStream(res.body));
217
+ carSizeBytes = byteLength;
218
+ rootData = blocks.get(roots[0]);
219
+ }
153
220
  if (!rootData)
154
221
  throw new Error(`No root block for ${did}`);
155
222
  const { value: commit } = cborDecode(rootData);
156
223
  // Walk MST to find all record paths
157
224
  const entries = walkMst(blocks, commit.data.$link);
158
225
  // Delete existing records for this DID before re-importing so deletions are reflected
159
- for (const col of collections) {
160
- const schema = getSchema(col);
161
- if (!schema)
162
- continue;
163
- await runSQL(`DELETE FROM ${schema.tableName} WHERE did = $1`, did);
164
- for (const child of schema.children) {
165
- await runSQL(`DELETE FROM ${child.tableName} WHERE parent_did = $1`, did);
166
- }
167
- for (const union of schema.unions) {
168
- for (const branch of union.branches) {
169
- await runSQL(`DELETE FROM ${branch.tableName} WHERE parent_did = $1`, did);
226
+ // Only on full imports (no since) — diff CARs only contain changes
227
+ if (!lastRev) {
228
+ for (const col of purgeableCollections(collections)) {
229
+ const schema = getSchema(col);
230
+ if (!schema)
231
+ continue;
232
+ await runSQL(`DELETE FROM ${schema.tableName} WHERE did = $1`, [did]);
233
+ for (const child of schema.children) {
234
+ await runSQL(`DELETE FROM ${child.tableName} WHERE parent_did = $1`, [did]);
235
+ }
236
+ for (const union of schema.unions) {
237
+ for (const branch of union.branches) {
238
+ await runSQL(`DELETE FROM ${branch.tableName} WHERE parent_did = $1`, [did]);
239
+ }
170
240
  }
171
241
  }
172
242
  }
173
243
  // Insert records in chunks to limit memory usage
174
244
  const CHUNK_SIZE = 1000;
175
245
  let chunk = [];
246
+ const validationSkips = {};
176
247
  for (const entry of entries) {
177
248
  const collection = entry.path.split('/')[0];
178
- if (!collections.has(collection))
249
+ if (!collections.has(collection) || isPrivateCollection(collection))
179
250
  continue;
180
251
  const blockData = blocks.get(entry.cid);
181
252
  if (!blockData)
@@ -187,6 +258,12 @@ export async function backfillRepo(did, collections, fetchTimeout) {
187
258
  continue;
188
259
  const rkey = entry.path.split('/').slice(1).join('/');
189
260
  const uri = `at://${did}/${collection}/${rkey}`;
261
+ const validationError = validateRecord(getLexiconArray(), collection, record);
262
+ if (validationError) {
263
+ validationSkips[collection] = (validationSkips[collection] || 0) + 1;
264
+ continue;
265
+ }
266
+ noteReferencedRepos(collection, rkey, record);
190
267
  chunk.push({ collection, uri, cid: entry.cid, did, record });
191
268
  if (chunk.length >= CHUNK_SIZE) {
192
269
  count += await bulkInsertRecords(chunk);
@@ -202,11 +279,13 @@ export async function backfillRepo(did, collections, fetchTimeout) {
202
279
  });
203
280
  }
204
281
  }
205
- blocks.free();
206
- blocks = null;
207
282
  if (chunk.length > 0) {
208
283
  count += await bulkInsertRecords(chunk);
209
284
  }
285
+ const totalSkips = Object.values(validationSkips).reduce((a, b) => a + b, 0);
286
+ if (totalSkips > 0) {
287
+ emit('backfill', 'validation_skips', { did, total: totalSkips, by_collection: validationSkips });
288
+ }
210
289
  await setRepoStatus(did, 'active', commit.rev, { handle });
211
290
  return count;
212
291
  }
@@ -238,6 +317,8 @@ export async function backfillRepo(did, collections, fetchTimeout) {
238
317
  error,
239
318
  pds_url: resolvedPds,
240
319
  car_size_bytes: carSizeBytes,
320
+ import_mode: carSizeBytes !== undefined ? (resolvedSince ? 'diff' : 'full') : undefined,
321
+ since_rev: resolvedSince,
241
322
  retry_count: retryCount,
242
323
  retry_after: retryAfter,
243
324
  permanent_failure: retryCount === 999 ? true : undefined,
@@ -313,26 +394,30 @@ export async function runBackfill(opts) {
313
394
  }
314
395
  }
315
396
  else if (config.fullNetwork) {
316
- for await (const repo of listRepos(pdsUrl)) {
317
- dids.add(repo.did);
397
+ for (const url of [pdsUrl, ...(opts.extraPdsUrls ?? [])]) {
398
+ for await (const repo of listRepos(url)) {
399
+ dids.add(repo.did);
400
+ }
318
401
  }
319
402
  }
320
403
  else {
321
- for (const col of signalCollections) {
322
- try {
323
- for await (const repo of listReposByCollection(pdsUrl, col)) {
324
- dids.add(repo.did);
325
- }
326
- }
327
- catch (err) {
328
- // Fall back to listRepos if listReposByCollection not supported
329
- if (err.message.includes('400') || err.message.includes('401') || err.message.includes('501')) {
330
- for await (const repo of listRepos(pdsUrl)) {
404
+ for (const url of [pdsUrl, ...(opts.extraPdsUrls ?? [])]) {
405
+ for (const col of signalCollections) {
406
+ try {
407
+ for await (const repo of listReposByCollection(url, col)) {
331
408
  dids.add(repo.did);
332
409
  }
333
- break;
334
410
  }
335
- throw err;
411
+ catch (err) {
412
+ // Fall back to listRepos if listReposByCollection not supported
413
+ if (err.message.includes('400') || err.message.includes('401') || err.message.includes('501')) {
414
+ for await (const repo of listRepos(url)) {
415
+ dids.add(repo.did);
416
+ }
417
+ break;
418
+ }
419
+ throw err;
420
+ }
336
421
  }
337
422
  }
338
423
  }
@@ -340,7 +425,7 @@ export async function runBackfill(opts) {
340
425
  const pending = [];
341
426
  for (const did of dids) {
342
427
  const status = await getRepoStatus(did);
343
- if (status !== 'active') {
428
+ if (backfillEligible(status)) {
344
429
  if (!status)
345
430
  await setRepoStatus(did, 'pending');
346
431
  pending.push(did);
@@ -363,7 +448,7 @@ export async function runBackfill(opts) {
363
448
  parallelism: config.parallelism,
364
449
  status: 'success',
365
450
  });
366
- return;
451
+ return 0;
367
452
  }
368
453
  // 3. Backfill with worker pool
369
454
  let totalRecords = 0;
@@ -387,7 +472,7 @@ export async function runBackfill(opts) {
387
472
  retryRound++;
388
473
  // Wait until the earliest retry_after has passed
389
474
  const now = Math.floor(Date.now() / 1000);
390
- const rows = await querySQL(`SELECT MIN(retry_after) as earliest FROM _repos WHERE status = 'failed' AND retry_after > $1 AND retry_count < $2`, [now, maxRetries]);
475
+ const rows = (await querySQL(`SELECT MIN(retry_after) as earliest FROM _repos WHERE status = 'failed' AND retry_after > $1 AND retry_count < $2`, [now, maxRetries]));
391
476
  const earliest = rows[0]?.earliest ? Number(rows[0].earliest) : 0;
392
477
  if (earliest > now) {
393
478
  await new Promise((resolve) => setTimeout(resolve, (earliest - now) * 1000));
@@ -421,4 +506,5 @@ export async function runBackfill(opts) {
421
506
  retry_rounds: retryRound,
422
507
  status: failedCount > 0 ? 'partial' : 'success',
423
508
  });
509
+ return totalRecords;
424
510
  }
package/dist/car.d.ts CHANGED
@@ -27,6 +27,26 @@ export declare class LazyBlockMap {
27
27
  /** Release the underlying CAR buffer */
28
28
  free(): void;
29
29
  }
30
+ /**
31
+ * Parses a CARv1 stream incrementally from a `ReadableStream`.
32
+ *
33
+ * Instead of buffering the entire CAR into a single ArrayBuffer, this reads
34
+ * chunks from the stream and parses blocks as they arrive. Each block's data
35
+ * is `.slice()`d into its own small `Uint8Array`, allowing V8 to GC individual
36
+ * blocks as they're consumed during the MST walk.
37
+ *
38
+ * This is critical for backfill where multiple workers download 30-90MB CARs
39
+ * concurrently — buffered downloads cause OOMs because `ArrayBuffer` memory
40
+ * is "external" to V8's heap and not controlled by `--max-old-space-size`.
41
+ *
42
+ * @param body - The response body stream (e.g. `res.body` from `fetch()`)
43
+ * @returns `roots` — root CID strings; `blocks` — map of CID → block data; `byteLength` — total bytes read
44
+ */
45
+ export declare function parseCarStream(body: ReadableStream<Uint8Array>): Promise<{
46
+ roots: string[];
47
+ blocks: Map<string, Uint8Array>;
48
+ byteLength: number;
49
+ }>;
30
50
  /**
31
51
  * Parses a CARv1 binary frame into its root CIDs and a lazy block map.
32
52
  *
package/dist/car.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"car.d.ts","sourceRoot":"","sources":["../src/car.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAuCH;;;;GAIG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,OAAO,CAA+B;IAC9C,OAAO,CAAC,QAAQ,CAAmB;gBAEvB,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAKxE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS;IAMxC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAI5B,IAAI,IAAI,IAAI,MAAM,CAEjB;IAEA,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,gBAAgB,CAAC,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IAO5D,wCAAwC;IACxC,IAAI,IAAI,IAAI;CAIb;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,UAAU,GAAG;IACnD,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,MAAM,EAAE,YAAY,CAAA;CACrB,CAiCA"}
1
+ {"version":3,"file":"car.d.ts","sourceRoot":"","sources":["../src/car.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAuCH;;;;GAIG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,OAAO,CAA+B;IAC9C,OAAO,CAAC,QAAQ,CAAmB;gBAEvB,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAKxE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS;IAMxC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAI5B,IAAI,IAAI,IAAI,MAAM,CAEjB;IAEA,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,gBAAgB,CAAC,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IAO5D,wCAAwC;IACxC,IAAI,IAAI,IAAI;CAIb;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC;IAC9E,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;IAC/B,UAAU,EAAE,MAAM,CAAA;CACnB,CAAC,CAsGD;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,UAAU,GAAG;IACnD,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,MAAM,EAAE,YAAY,CAAA;CACrB,CAiCA"}
package/dist/car.js CHANGED
@@ -78,6 +78,114 @@ export class LazyBlockMap {
78
78
  this.offsets.clear();
79
79
  }
80
80
  }
81
+ /**
82
+ * Parses a CARv1 stream incrementally from a `ReadableStream`.
83
+ *
84
+ * Instead of buffering the entire CAR into a single ArrayBuffer, this reads
85
+ * chunks from the stream and parses blocks as they arrive. Each block's data
86
+ * is `.slice()`d into its own small `Uint8Array`, allowing V8 to GC individual
87
+ * blocks as they're consumed during the MST walk.
88
+ *
89
+ * This is critical for backfill where multiple workers download 30-90MB CARs
90
+ * concurrently — buffered downloads cause OOMs because `ArrayBuffer` memory
91
+ * is "external" to V8's heap and not controlled by `--max-old-space-size`.
92
+ *
93
+ * @param body - The response body stream (e.g. `res.body` from `fetch()`)
94
+ * @returns `roots` — root CID strings; `blocks` — map of CID → block data; `byteLength` — total bytes read
95
+ */
96
+ export async function parseCarStream(body) {
97
+ const reader = body.getReader();
98
+ // Growable buffer with position tracking. We reuse a single allocation and
99
+ // compact (shift data to front) when the read position passes the midpoint,
100
+ // avoiding per-chunk allocations and subarray references that pin old memory.
101
+ let buf = new Uint8Array(64 * 1024);
102
+ let pos = 0; // read cursor
103
+ let len = 0; // bytes of valid data in buf
104
+ let byteLength = 0;
105
+ // Ensure at least `need` bytes are available at buf[pos..pos+need)
106
+ async function fill(need) {
107
+ while (len - pos < need) {
108
+ const { done, value } = await reader.read();
109
+ if (done)
110
+ return len - pos >= need;
111
+ byteLength += value.length;
112
+ // Compact: shift remaining data to front when read cursor passes midpoint
113
+ if (pos > 0 && pos > buf.length >>> 1) {
114
+ buf.copyWithin(0, pos, len);
115
+ len -= pos;
116
+ pos = 0;
117
+ }
118
+ // Grow if needed
119
+ const required = len + value.length;
120
+ if (required > buf.length) {
121
+ const newBuf = new Uint8Array(Math.max(required, buf.length * 2));
122
+ newBuf.set(buf.subarray(0, len));
123
+ buf = newBuf;
124
+ }
125
+ buf.set(value, len);
126
+ len += value.length;
127
+ }
128
+ return true;
129
+ }
130
+ function consume(n) {
131
+ pos += n;
132
+ }
133
+ // Read a varint starting at buf[pos]
134
+ function readVarintFromBuf() {
135
+ let value = 0;
136
+ let shift = 0;
137
+ let p = pos;
138
+ while (p < len) {
139
+ const byte = buf[p++];
140
+ value |= (byte & 0x7f) << shift;
141
+ if ((byte & 0x80) === 0)
142
+ return [value, p - pos];
143
+ shift += 7;
144
+ if (shift > 35)
145
+ throw new Error('Varint too long');
146
+ }
147
+ throw new Error('Unexpected end of varint');
148
+ }
149
+ // Parse header: varint(headerLen) + CBOR(header)
150
+ if (!(await fill(1)))
151
+ throw new Error('Empty CAR stream');
152
+ // Prefetch up to 10 bytes for the varint; readVarintFromBuf bounds to `len`
153
+ await fill(10);
154
+ const [headerLen, headerVarintSize] = readVarintFromBuf();
155
+ consume(headerVarintSize);
156
+ if (!(await fill(headerLen)))
157
+ throw new Error('Truncated CAR header');
158
+ // .slice() copies out of the reusable buffer
159
+ const headerSlice = buf.slice(pos, pos + headerLen);
160
+ const { value: header } = cborDecode(headerSlice);
161
+ consume(headerLen);
162
+ const roots = (header.roots || []).map((root) => root?.$link ?? cidToString(root));
163
+ // Parse blocks
164
+ const blocks = new Map();
165
+ while (true) {
166
+ if (!(await fill(1)))
167
+ break;
168
+ // Prefetch up to 10 bytes for the varint; readVarintFromBuf bounds to `len`
169
+ await fill(10);
170
+ const [blockLen, blockVarintSize] = readVarintFromBuf();
171
+ consume(blockVarintSize);
172
+ if (blockLen === 0)
173
+ break;
174
+ if (!(await fill(blockLen)))
175
+ throw new Error('Truncated CAR block');
176
+ const [cidBytes, afterCid] = parseCidFromBytes(buf, pos);
177
+ const cid = cidToString(cidBytes);
178
+ const cidLen = afterCid - pos;
179
+ // .slice() creates an independent copy — the buffer can be reused
180
+ const data = buf.slice(afterCid, afterCid + blockLen - cidLen);
181
+ blocks.set(cid, data);
182
+ consume(blockLen);
183
+ }
184
+ reader.releaseLock();
185
+ // Release the internal buffer
186
+ buf = null;
187
+ return { roots, blocks, byteLength };
188
+ }
81
189
  /**
82
190
  * Parses a CARv1 binary frame into its root CIDs and a lazy block map.
83
191
  *