@hatk/hatk 0.0.1-alpha.8 → 0.0.1-alpha.81

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 (175) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +111 -0
  4. package/dist/backfill.d.ts +29 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +136 -51
  7. package/dist/car.d.ts +42 -10
  8. package/dist/car.d.ts.map +1 -1
  9. package/dist/car.js +154 -14
  10. package/dist/cli.js +243 -1043
  11. package/dist/config.d.ts +82 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +44 -9
  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} +57 -6
  30. package/dist/database/db.d.ts.map +1 -0
  31. package/dist/{db.js → database/db.js} +755 -552
  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} +116 -32
  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} +14 -3
  45. package/dist/database/schema.d.ts.map +1 -0
  46. package/dist/{schema.js → database/schema.js} +81 -41
  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 +125 -0
  50. package/dist/feeds.d.ts +12 -8
  51. package/dist/feeds.d.ts.map +1 -1
  52. package/dist/feeds.js +51 -6
  53. package/dist/hooks.d.ts +85 -0
  54. package/dist/hooks.d.ts.map +1 -0
  55. package/dist/hooks.js +163 -0
  56. package/dist/hydrate.d.ts +7 -6
  57. package/dist/hydrate.d.ts.map +1 -1
  58. package/dist/hydrate.js +4 -16
  59. package/dist/indexer.d.ts +133 -3
  60. package/dist/indexer.d.ts.map +1 -1
  61. package/dist/indexer.js +406 -66
  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/lexicon-resolve.d.ts.map +1 -1
  69. package/dist/lexicon-resolve.js +27 -112
  70. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  71. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  72. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  73. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  74. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  75. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  76. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  77. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  78. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  79. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  80. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  81. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  82. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  83. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  84. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  85. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  86. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  87. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  88. package/dist/logger.d.ts +29 -0
  89. package/dist/logger.d.ts.map +1 -1
  90. package/dist/logger.js +29 -0
  91. package/dist/main.js +169 -74
  92. package/dist/mst.d.ts +18 -1
  93. package/dist/mst.d.ts.map +1 -1
  94. package/dist/mst.js +19 -8
  95. package/dist/oauth/db.d.ts +3 -1
  96. package/dist/oauth/db.d.ts.map +1 -1
  97. package/dist/oauth/db.js +56 -19
  98. package/dist/oauth/discovery.d.ts.map +1 -1
  99. package/dist/oauth/discovery.js +13 -1
  100. package/dist/oauth/server.d.ts +92 -1
  101. package/dist/oauth/server.d.ts.map +1 -1
  102. package/dist/oauth/server.js +352 -37
  103. package/dist/oauth/service-describe.d.ts +31 -0
  104. package/dist/oauth/service-describe.d.ts.map +1 -0
  105. package/dist/oauth/service-describe.js +61 -0
  106. package/dist/oauth/session.d.ts +32 -0
  107. package/dist/oauth/session.d.ts.map +1 -0
  108. package/dist/oauth/session.js +131 -0
  109. package/dist/opengraph.d.ts +10 -0
  110. package/dist/opengraph.d.ts.map +1 -1
  111. package/dist/opengraph.js +80 -40
  112. package/dist/pds-proxy.d.ts +82 -0
  113. package/dist/pds-proxy.d.ts.map +1 -0
  114. package/dist/pds-proxy.js +356 -0
  115. package/dist/private-collections.d.ts +8 -0
  116. package/dist/private-collections.d.ts.map +1 -0
  117. package/dist/private-collections.js +22 -0
  118. package/dist/push.d.ts +54 -0
  119. package/dist/push.d.ts.map +1 -0
  120. package/dist/push.js +367 -0
  121. package/dist/renderer.d.ts +27 -0
  122. package/dist/renderer.d.ts.map +1 -0
  123. package/dist/renderer.js +46 -0
  124. package/dist/resolve-hatk.d.ts +6 -0
  125. package/dist/resolve-hatk.d.ts.map +1 -0
  126. package/dist/resolve-hatk.js +20 -0
  127. package/dist/response.d.ts +16 -0
  128. package/dist/response.d.ts.map +1 -0
  129. package/dist/response.js +69 -0
  130. package/dist/scanner.d.ts +21 -0
  131. package/dist/scanner.d.ts.map +1 -0
  132. package/dist/scanner.js +88 -0
  133. package/dist/seed.d.ts +19 -0
  134. package/dist/seed.d.ts.map +1 -1
  135. package/dist/seed.js +43 -4
  136. package/dist/server-init.d.ts +8 -0
  137. package/dist/server-init.d.ts.map +1 -0
  138. package/dist/server-init.js +62 -0
  139. package/dist/server.d.ts +26 -3
  140. package/dist/server.d.ts.map +1 -1
  141. package/dist/server.js +858 -638
  142. package/dist/setup.d.ts +28 -1
  143. package/dist/setup.d.ts.map +1 -1
  144. package/dist/setup.js +50 -3
  145. package/dist/templates/feed.tpl +14 -0
  146. package/dist/templates/hook.tpl +5 -0
  147. package/dist/templates/label.tpl +15 -0
  148. package/dist/templates/og.tpl +17 -0
  149. package/dist/templates/seed.tpl +11 -0
  150. package/dist/templates/setup.tpl +5 -0
  151. package/dist/templates/test-feed.tpl +19 -0
  152. package/dist/templates/test-xrpc.tpl +19 -0
  153. package/dist/templates/xrpc.tpl +41 -0
  154. package/dist/test.d.ts +1 -1
  155. package/dist/test.d.ts.map +1 -1
  156. package/dist/test.js +41 -32
  157. package/dist/views.js +1 -1
  158. package/dist/vite-plugin.d.ts +1 -1
  159. package/dist/vite-plugin.d.ts.map +1 -1
  160. package/dist/vite-plugin.js +254 -66
  161. package/dist/xrpc.d.ts +84 -11
  162. package/dist/xrpc.d.ts.map +1 -1
  163. package/dist/xrpc.js +211 -41
  164. package/package.json +17 -9
  165. package/public/admin-auth.js +271 -306
  166. package/public/admin.html +177 -61
  167. package/dist/db.d.ts.map +0 -1
  168. package/dist/fts.d.ts.map +0 -1
  169. package/dist/oauth/hooks.d.ts +0 -10
  170. package/dist/oauth/hooks.d.ts.map +0 -1
  171. package/dist/oauth/hooks.js +0 -40
  172. package/dist/schema.d.ts.map +0 -1
  173. package/dist/test-browser.d.ts +0 -14
  174. package/dist/test-browser.d.ts.map +0 -1
  175. 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,UAiBvB,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,111 @@
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
+ '/oauth/',
63
+ '/oauth-client-metadata.json',
64
+ '/.well-known/oauth-authorization-server',
65
+ '/.well-known/oauth-protected-resource',
66
+ '/og/',
67
+ '/admin',
68
+ '/repos',
69
+ '/info/',
70
+ '/_health',
71
+ '/robots.txt',
72
+ '/auth/logout',
73
+ '/auth/switch',
74
+ '/auth/accounts',
75
+ '/__dev/',
76
+ ];
77
+ export function isHatkRoute(pathname) {
78
+ return HATK_ROUTES.some((r) => pathname.startsWith(r) || pathname === r);
79
+ }
80
+ /**
81
+ * Create a Node.js HTTP server from a Web Standard fetch handler.
82
+ * If a fallback Node middleware is provided, non-hatk routes are sent to it
83
+ * (e.g. SvelteKit's handler from build/handler.js).
84
+ */
85
+ export function serve(handler, port, base, fallback) {
86
+ const origin = base || `http://localhost:${port}`;
87
+ const server = createServer(async (req, res) => {
88
+ try {
89
+ const url = new URL(req.url, origin);
90
+ // If we have a fallback (e.g. SvelteKit) and this isn't a hatk route, skip hatk
91
+ if (fallback && !isHatkRoute(url.pathname)) {
92
+ fallback(req, res, () => {
93
+ res.writeHead(404);
94
+ res.end('Not found');
95
+ });
96
+ return;
97
+ }
98
+ const request = toRequest(req, origin);
99
+ const response = await handler(request);
100
+ await sendResponse(res, response);
101
+ }
102
+ catch (err) {
103
+ if (!res.headersSent) {
104
+ res.writeHead(500, { 'Content-Type': 'application/json' });
105
+ }
106
+ res.end(JSON.stringify({ error: err.message }));
107
+ }
108
+ });
109
+ server.listen(port);
110
+ return server;
111
+ }
@@ -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,CA2H/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":"AAiBA;;;;;;;;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,CAyK/G;AA8BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAsIrE"}
package/dist/backfill.js CHANGED
@@ -1,16 +1,58 @@
1
- import { parseCarFrame } from "./car.js";
1
+ import { parseCarStream } from "./car.js";
2
2
  import { cborDecode } from "./cbor.js";
3
3
  import { walkMst } from "./mst.js";
4
- import { setRepoStatus, getRepoStatus, getRepoRetryInfo, listRetryEligibleRepos, listPendingRepos, querySQL, runSQL, getSchema, bulkInsertRecords, } from "./db.js";
4
+ import { setRepoStatus, getRepoStatus, getRepoRev, getRepoRetryInfo, listRetryEligibleRepos, listPendingRepos, querySQL, runSQL, getSchema, bulkInsertRecords, } from "./database/db.js";
5
+ import { isPrivateCollection } from "./private-collections.js";
6
+ /**
7
+ * The collections whose local rows a full re-import may purge before
8
+ * re-inserting from the repo CAR.
9
+ *
10
+ * Private collections are AppView-authoritative and never present in a repo:
11
+ * purging their rows would destroy data the re-import can never restore (this
12
+ * happened — a login-triggered backfill wiped a deployment's activity rows).
13
+ * Exported for tests.
14
+ */
15
+ export function purgeableCollections(collections) {
16
+ return [...collections].filter((c) => !isPrivateCollection(c));
17
+ }
18
+ /**
19
+ * Whether a repo with this stored status should be queued for backfill.
20
+ *
21
+ * Takendown repos are excluded: re-backfilling one ends in
22
+ * `setRepoStatus(did, 'active')`, which would silently reinstate an account an
23
+ * admin took down — takedowns must survive every redeploy. Reinstatement goes
24
+ * through `/admin/reverse-takedown` only. Exported for tests.
25
+ */
26
+ export function backfillEligible(status) {
27
+ return status !== 'active' && status !== 'takendown';
28
+ }
5
29
  import { emit, timer } from "./logger.js";
6
- /** In-memory cache of DID → PDS resolution results to avoid redundant lookups. */
7
- const pdsCache = new Map();
30
+ import { validateRecord } from '@bigmoves/lexicon';
31
+ import { getLexiconArray } from "./database/schema.js";
8
32
  let plcUrl;
33
+ /** Point DID resolution at a PLC directory. `runBackfill` does this too; call it
34
+ * at boot so resolvers that run before the first backfill have somewhere to go. */
35
+ export function configurePlc(url) {
36
+ plcUrl = url;
37
+ }
38
+ const pdsCache = new Map();
39
+ /** A repo's PDS endpoint, cached for the process. For serving blobs, where a
40
+ * stale answer after a migration costs one broken image, not a wrong row. */
41
+ export async function pdsFor(did) {
42
+ const cached = pdsCache.get(did);
43
+ if (cached)
44
+ return cached;
45
+ const { pds } = await resolvePds(did);
46
+ pdsCache.set(did, pds);
47
+ return pds;
48
+ }
9
49
  /**
10
50
  * Resolves a DID to its PDS endpoint and handle by fetching the DID document.
11
51
  *
12
52
  * 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.
53
+ * (fetches from the PLC directory). Always fetches fresh — DID docs change
54
+ * (handle renames, PDS migrations) and a stale cache silently rewrites stale
55
+ * handles back into `_repos` on every backfill.
14
56
  *
15
57
  * @example
16
58
  * ```ts
@@ -20,9 +62,6 @@ let plcUrl;
20
62
  * ```
21
63
  */
22
64
  async function resolvePds(did) {
23
- const cached = pdsCache.get(did);
24
- if (cached)
25
- return cached;
26
65
  let didDoc;
27
66
  if (did.startsWith('did:web:')) {
28
67
  const domain = did.slice('did:web:'.length);
@@ -40,12 +79,10 @@ async function resolvePds(did) {
40
79
  const pds = didDoc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint;
41
80
  if (!pds)
42
81
  throw new Error(`No PDS endpoint in DID document for ${did}`);
43
- // Extract handle from alsoKnownAs (format: "at://handle")
82
+ // First at:// entry in alsoKnownAs is the canonical handle (per @atproto/identity convention)
44
83
  const aka = didDoc.alsoKnownAs?.find((u) => u.startsWith('at://'));
45
84
  const handle = aka ? aka.slice('at://'.length) : null;
46
- const result = { pds, handle };
47
- pdsCache.set(did, result);
48
- return result;
85
+ return { pds, handle };
49
86
  }
50
87
  /**
51
88
  * Paginates through all active repos on a relay/PDS using `com.atproto.sync.listRepos`.
@@ -121,6 +158,13 @@ async function* listReposByCollection(pdsUrl, collection) {
121
158
  * ```
122
159
  */
123
160
  export async function backfillRepo(did, collections, fetchTimeout) {
161
+ // A takendown repo must never be re-imported: the success path ends in
162
+ // setRepoStatus('active'), which would reinstate the account. Guarding here
163
+ // covers every caller (boot scan, auto-backfill, admin re-index).
164
+ if ((await getRepoStatus(did)) === 'takendown') {
165
+ emit('backfill', 'takedown_skip', { did });
166
+ return 0;
167
+ }
124
168
  const elapsed = timer();
125
169
  let count = 0;
126
170
  let carSizeBytes;
@@ -128,6 +172,7 @@ export async function backfillRepo(did, collections, fetchTimeout) {
128
172
  let error;
129
173
  let resolvedPds;
130
174
  let resolvedHandle = null;
175
+ let resolvedSince = null;
131
176
  let retryCount;
132
177
  let retryAfter;
133
178
  const controller = new AbortController();
@@ -137,56 +182,86 @@ export async function backfillRepo(did, collections, fetchTimeout) {
137
182
  resolvedPds = pdsUrl;
138
183
  resolvedHandle = handle;
139
184
  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
- });
185
+ let lastRev = await getRepoRev(did);
186
+ const baseUrl = `${resolvedPds}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`;
187
+ let repoUrl = lastRev ? `${baseUrl}&since=${encodeURIComponent(lastRev)}` : baseUrl;
188
+ let res = await fetch(repoUrl, { signal: controller.signal });
189
+ // If the PDS rejected our `since` rev (compacted history), fall back to full import
190
+ if (res.status === 400 && lastRev) {
191
+ lastRev = null;
192
+ res = await fetch(baseUrl, { signal: controller.signal });
193
+ }
143
194
  if (!res.ok) {
144
195
  const httpErr = new Error(`getRepo failed for ${did}: ${res.status}`);
145
196
  httpErr.httpStatus = res.status;
146
197
  throw httpErr;
147
198
  }
148
- let carBytes = new Uint8Array(await res.arrayBuffer());
149
- carSizeBytes = carBytes.length;
150
- let { roots, blocks } = parseCarFrame(carBytes);
151
- carBytes = null; // free CAR bytes before bulk insert
152
- // Decode commit to get MST root
153
- const rootData = blocks.get(roots[0]);
199
+ resolvedSince = lastRev;
200
+ let { roots, blocks, byteLength } = await parseCarStream(res.body);
201
+ carSizeBytes = byteLength;
202
+ // Decode commit to get MST root — if the diff CAR is missing the root block,
203
+ // fall back to a full import (the PDS compacted past our `since` rev)
204
+ let rootData = blocks.get(roots[0]);
205
+ if (!rootData && lastRev) {
206
+ lastRev = null;
207
+ resolvedSince = null;
208
+ res = await fetch(baseUrl, { signal: controller.signal });
209
+ if (!res.ok) {
210
+ const httpErr = new Error(`getRepo failed for ${did}: ${res.status}`);
211
+ httpErr.httpStatus = res.status;
212
+ throw httpErr;
213
+ }
214
+ ;
215
+ ({ roots, blocks, byteLength } = await parseCarStream(res.body));
216
+ carSizeBytes = byteLength;
217
+ rootData = blocks.get(roots[0]);
218
+ }
154
219
  if (!rootData)
155
220
  throw new Error(`No root block for ${did}`);
156
221
  const { value: commit } = cborDecode(rootData);
157
222
  // Walk MST to find all record paths
158
223
  const entries = walkMst(blocks, commit.data.$link);
159
224
  // Delete existing records for this DID before re-importing so deletions are reflected
160
- for (const col of collections) {
161
- const schema = getSchema(col);
162
- if (!schema)
163
- continue;
164
- await runSQL(`DELETE FROM ${schema.tableName} WHERE did = $1`, did);
165
- for (const child of schema.children) {
166
- await runSQL(`DELETE FROM ${child.tableName} WHERE parent_did = $1`, did);
167
- }
168
- for (const union of schema.unions) {
169
- for (const branch of union.branches) {
170
- await runSQL(`DELETE FROM ${branch.tableName} WHERE parent_did = $1`, did);
225
+ // Only on full imports (no since) — diff CARs only contain changes
226
+ if (!lastRev) {
227
+ for (const col of purgeableCollections(collections)) {
228
+ const schema = getSchema(col);
229
+ if (!schema)
230
+ continue;
231
+ await runSQL(`DELETE FROM ${schema.tableName} WHERE did = $1`, [did]);
232
+ for (const child of schema.children) {
233
+ await runSQL(`DELETE FROM ${child.tableName} WHERE parent_did = $1`, [did]);
234
+ }
235
+ for (const union of schema.unions) {
236
+ for (const branch of union.branches) {
237
+ await runSQL(`DELETE FROM ${branch.tableName} WHERE parent_did = $1`, [did]);
238
+ }
171
239
  }
172
240
  }
173
241
  }
174
242
  // Insert records in chunks to limit memory usage
175
243
  const CHUNK_SIZE = 1000;
176
244
  let chunk = [];
245
+ const validationSkips = {};
177
246
  for (const entry of entries) {
178
247
  const collection = entry.path.split('/')[0];
179
- if (!collections.has(collection))
248
+ if (!collections.has(collection) || isPrivateCollection(collection))
180
249
  continue;
181
250
  const blockData = blocks.get(entry.cid);
182
251
  if (!blockData)
183
252
  continue;
253
+ blocks.delete(entry.cid); // free block data as we go
184
254
  try {
185
255
  const { value: record } = cborDecode(blockData);
186
256
  if (!record?.$type)
187
257
  continue;
188
258
  const rkey = entry.path.split('/').slice(1).join('/');
189
259
  const uri = `at://${did}/${collection}/${rkey}`;
260
+ const validationError = validateRecord(getLexiconArray(), collection, record);
261
+ if (validationError) {
262
+ validationSkips[collection] = (validationSkips[collection] || 0) + 1;
263
+ continue;
264
+ }
190
265
  chunk.push({ collection, uri, cid: entry.cid, did, record });
191
266
  if (chunk.length >= CHUNK_SIZE) {
192
267
  count += await bulkInsertRecords(chunk);
@@ -202,10 +277,13 @@ export async function backfillRepo(did, collections, fetchTimeout) {
202
277
  });
203
278
  }
204
279
  }
205
- blocks = null; // free block map
206
280
  if (chunk.length > 0) {
207
281
  count += await bulkInsertRecords(chunk);
208
282
  }
283
+ const totalSkips = Object.values(validationSkips).reduce((a, b) => a + b, 0);
284
+ if (totalSkips > 0) {
285
+ emit('backfill', 'validation_skips', { did, total: totalSkips, by_collection: validationSkips });
286
+ }
209
287
  await setRepoStatus(did, 'active', commit.rev, { handle });
210
288
  return count;
211
289
  }
@@ -237,6 +315,8 @@ export async function backfillRepo(did, collections, fetchTimeout) {
237
315
  error,
238
316
  pds_url: resolvedPds,
239
317
  car_size_bytes: carSizeBytes,
318
+ import_mode: carSizeBytes !== undefined ? (resolvedSince ? 'diff' : 'full') : undefined,
319
+ since_rev: resolvedSince,
240
320
  retry_count: retryCount,
241
321
  retry_after: retryAfter,
242
322
  permanent_failure: retryCount === 999 ? true : undefined,
@@ -312,26 +392,30 @@ export async function runBackfill(opts) {
312
392
  }
313
393
  }
314
394
  else if (config.fullNetwork) {
315
- for await (const repo of listRepos(pdsUrl)) {
316
- dids.add(repo.did);
395
+ for (const url of [pdsUrl, ...(opts.extraPdsUrls ?? [])]) {
396
+ for await (const repo of listRepos(url)) {
397
+ dids.add(repo.did);
398
+ }
317
399
  }
318
400
  }
319
401
  else {
320
- for (const col of signalCollections) {
321
- try {
322
- for await (const repo of listReposByCollection(pdsUrl, col)) {
323
- dids.add(repo.did);
324
- }
325
- }
326
- catch (err) {
327
- // Fall back to listRepos if listReposByCollection not supported
328
- if (err.message.includes('400') || err.message.includes('401') || err.message.includes('501')) {
329
- for await (const repo of listRepos(pdsUrl)) {
402
+ for (const url of [pdsUrl, ...(opts.extraPdsUrls ?? [])]) {
403
+ for (const col of signalCollections) {
404
+ try {
405
+ for await (const repo of listReposByCollection(url, col)) {
330
406
  dids.add(repo.did);
331
407
  }
332
- break;
333
408
  }
334
- throw err;
409
+ catch (err) {
410
+ // Fall back to listRepos if listReposByCollection not supported
411
+ if (err.message.includes('400') || err.message.includes('401') || err.message.includes('501')) {
412
+ for await (const repo of listRepos(url)) {
413
+ dids.add(repo.did);
414
+ }
415
+ break;
416
+ }
417
+ throw err;
418
+ }
335
419
  }
336
420
  }
337
421
  }
@@ -339,7 +423,7 @@ export async function runBackfill(opts) {
339
423
  const pending = [];
340
424
  for (const did of dids) {
341
425
  const status = await getRepoStatus(did);
342
- if (status !== 'active') {
426
+ if (backfillEligible(status)) {
343
427
  if (!status)
344
428
  await setRepoStatus(did, 'pending');
345
429
  pending.push(did);
@@ -362,7 +446,7 @@ export async function runBackfill(opts) {
362
446
  parallelism: config.parallelism,
363
447
  status: 'success',
364
448
  });
365
- return;
449
+ return 0;
366
450
  }
367
451
  // 3. Backfill with worker pool
368
452
  let totalRecords = 0;
@@ -386,7 +470,7 @@ export async function runBackfill(opts) {
386
470
  retryRound++;
387
471
  // Wait until the earliest retry_after has passed
388
472
  const now = Math.floor(Date.now() / 1000);
389
- 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]);
473
+ 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]));
390
474
  const earliest = rows[0]?.earliest ? Number(rows[0].earliest) : 0;
391
475
  if (earliest > now) {
392
476
  await new Promise((resolve) => setTimeout(resolve, (earliest - now) * 1000));
@@ -420,4 +504,5 @@ export async function runBackfill(opts) {
420
504
  retry_rounds: retryRound,
421
505
  status: failedCount > 0 ? 'partial' : 'success',
422
506
  });
507
+ return totalRecords;
423
508
  }
package/dist/car.d.ts CHANGED
@@ -12,20 +12,52 @@
12
12
  * @module
13
13
  */
14
14
  /**
15
- * Parses a CARv1 binary frame into its root CIDs and block map.
15
+ * A memory-efficient block map that stores byte offsets into the original CAR
16
+ * buffer instead of copying block data. Implements the same `get`/`delete`/`size`
17
+ * interface as `Map<string, Uint8Array>` so it can be used as a drop-in replacement.
18
+ */
19
+ export declare class LazyBlockMap {
20
+ private offsets;
21
+ private carBytes;
22
+ constructor(carBytes: Uint8Array, offsets: Map<string, [number, number]>);
23
+ get(cid: string): Uint8Array | undefined;
24
+ delete(cid: string): boolean;
25
+ get size(): number;
26
+ [Symbol.iterator](): IterableIterator<[string, Uint8Array]>;
27
+ /** Release the underlying CAR buffer */
28
+ free(): void;
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
+ }>;
50
+ /**
51
+ * Parses a CARv1 binary frame into its root CIDs and a lazy block map.
52
+ *
53
+ * The block map stores byte offsets into `carBytes` rather than copying data,
54
+ * reducing heap usage from O(total block bytes) to O(number of blocks * 16 bytes).
16
55
  *
17
56
  * @param carBytes - Raw CAR file bytes (e.g. from `getRepo` or a firehose commit)
18
- * @returns `roots` — ordered list of root CID strings; `blocks` — map of CID string → raw block data
19
- *
20
- * @example
21
- * ```ts
22
- * const car = new Uint8Array(await res.arrayBuffer())
23
- * const { roots, blocks } = parseCarFrame(car)
24
- * const commitData = blocks.get(roots[0])
25
- * ```
57
+ * @returns `roots` — ordered list of root CID strings; `blocks` — lazy block map
26
58
  */
27
59
  export declare function parseCarFrame(carBytes: Uint8Array): {
28
60
  roots: string[];
29
- blocks: Map<string, Uint8Array>;
61
+ blocks: LazyBlockMap;
30
62
  };
31
63
  //# sourceMappingURL=car.d.ts.map
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;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,UAAU,GAAG;IACnD,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;CAChC,CAmCA"}
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"}