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

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 +110 -0
  4. package/dist/backfill.d.ts +21 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +102 -37
  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 +74 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +43 -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 +23 -0
  24. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  25. package/dist/database/adapters/sqlite-search.js +74 -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 +119 -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 +109 -3
  60. package/dist/indexer.d.ts.map +1 -1
  61. package/dist/indexer.js +338 -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 +161 -72
  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 +832 -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 +82 -11
  162. package/dist/xrpc.d.ts.map +1 -1
  163. package/dist/xrpc.js +196 -39
  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,UAgBvB,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,110 @@
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
+ '/oauth/',
62
+ '/oauth-client-metadata.json',
63
+ '/.well-known/oauth-authorization-server',
64
+ '/.well-known/oauth-protected-resource',
65
+ '/og/',
66
+ '/admin',
67
+ '/repos',
68
+ '/info/',
69
+ '/_health',
70
+ '/robots.txt',
71
+ '/auth/logout',
72
+ '/auth/switch',
73
+ '/auth/accounts',
74
+ '/__dev/',
75
+ ];
76
+ export function isHatkRoute(pathname) {
77
+ return HATK_ROUTES.some((r) => pathname.startsWith(r) || pathname === r);
78
+ }
79
+ /**
80
+ * Create a Node.js HTTP server from a Web Standard fetch handler.
81
+ * If a fallback Node middleware is provided, non-hatk routes are sent to it
82
+ * (e.g. SvelteKit's handler from build/handler.js).
83
+ */
84
+ export function serve(handler, port, base, fallback) {
85
+ const origin = base || `http://localhost:${port}`;
86
+ const server = createServer(async (req, res) => {
87
+ try {
88
+ const url = new URL(req.url, origin);
89
+ // If we have a fallback (e.g. SvelteKit) and this isn't a hatk route, skip hatk
90
+ if (fallback && !isHatkRoute(url.pathname)) {
91
+ fallback(req, res, () => {
92
+ res.writeHead(404);
93
+ res.end('Not found');
94
+ });
95
+ return;
96
+ }
97
+ const request = toRequest(req, origin);
98
+ const response = await handler(request);
99
+ await sendResponse(res, response);
100
+ }
101
+ catch (err) {
102
+ if (!res.headersSent) {
103
+ res.writeHead(500, { 'Content-Type': 'application/json' });
104
+ }
105
+ res.end(JSON.stringify({ error: err.message }));
106
+ }
107
+ });
108
+ server.listen(port);
109
+ return server;
110
+ }
@@ -1,3 +1,22 @@
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 {
@@ -7,7 +26,7 @@ interface BackfillOpts {
7
26
  plcUrl: string;
8
27
  /** AT Protocol collection NSIDs to index (e.g. `app.bsky.feed.post`). */
9
28
  collections: Set<string>;
10
- /** Backfill behavior settings from `config.yaml`. */
29
+ /** Backfill behavior settings from `hatk.config.ts`. */
11
30
  config: BackfillConfig;
12
31
  }
13
32
  /**
@@ -65,6 +84,6 @@ export declare function backfillRepo(did: string, collections: Set<string>, fetc
65
84
  * })
66
85
  * ```
67
86
  */
68
- export declare function runBackfill(opts: BackfillOpts): Promise<void>;
87
+ export declare function runBackfill(opts: BackfillOpts): Promise<number>;
69
88
  export {};
70
89
  //# 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,8FAA8F;IAC9F,MAAM,EAAE,MAAM,CAAA;IACd,yEAAyE;IACzE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB,wDAAwD;IACxD,MAAM,EAAE,cAAc,CAAA;CACvB;AA+FD;;;;;;;;;;;;;;;;;;;;;;;;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,CAkIrE"}
package/dist/backfill.js CHANGED
@@ -1,16 +1,42 @@
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;
9
33
  /**
10
34
  * Resolves a DID to its PDS endpoint and handle by fetching the DID document.
11
35
  *
12
36
  * 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.
37
+ * (fetches from the PLC directory). Always fetches fresh — DID docs change
38
+ * (handle renames, PDS migrations) and a stale cache silently rewrites stale
39
+ * handles back into `_repos` on every backfill.
14
40
  *
15
41
  * @example
16
42
  * ```ts
@@ -20,9 +46,6 @@ let plcUrl;
20
46
  * ```
21
47
  */
22
48
  async function resolvePds(did) {
23
- const cached = pdsCache.get(did);
24
- if (cached)
25
- return cached;
26
49
  let didDoc;
27
50
  if (did.startsWith('did:web:')) {
28
51
  const domain = did.slice('did:web:'.length);
@@ -40,12 +63,10 @@ async function resolvePds(did) {
40
63
  const pds = didDoc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint;
41
64
  if (!pds)
42
65
  throw new Error(`No PDS endpoint in DID document for ${did}`);
43
- // Extract handle from alsoKnownAs (format: "at://handle")
66
+ // First at:// entry in alsoKnownAs is the canonical handle (per @atproto/identity convention)
44
67
  const aka = didDoc.alsoKnownAs?.find((u) => u.startsWith('at://'));
45
68
  const handle = aka ? aka.slice('at://'.length) : null;
46
- const result = { pds, handle };
47
- pdsCache.set(did, result);
48
- return result;
69
+ return { pds, handle };
49
70
  }
50
71
  /**
51
72
  * Paginates through all active repos on a relay/PDS using `com.atproto.sync.listRepos`.
@@ -121,6 +142,13 @@ async function* listReposByCollection(pdsUrl, collection) {
121
142
  * ```
122
143
  */
123
144
  export async function backfillRepo(did, collections, fetchTimeout) {
145
+ // A takendown repo must never be re-imported: the success path ends in
146
+ // setRepoStatus('active'), which would reinstate the account. Guarding here
147
+ // covers every caller (boot scan, auto-backfill, admin re-index).
148
+ if ((await getRepoStatus(did)) === 'takendown') {
149
+ emit('backfill', 'takedown_skip', { did });
150
+ return 0;
151
+ }
124
152
  const elapsed = timer();
125
153
  let count = 0;
126
154
  let carSizeBytes;
@@ -128,6 +156,7 @@ export async function backfillRepo(did, collections, fetchTimeout) {
128
156
  let error;
129
157
  let resolvedPds;
130
158
  let resolvedHandle = null;
159
+ let resolvedSince = null;
131
160
  let retryCount;
132
161
  let retryAfter;
133
162
  const controller = new AbortController();
@@ -137,56 +166,86 @@ export async function backfillRepo(did, collections, fetchTimeout) {
137
166
  resolvedPds = pdsUrl;
138
167
  resolvedHandle = handle;
139
168
  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
- });
169
+ let lastRev = await getRepoRev(did);
170
+ const baseUrl = `${resolvedPds}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`;
171
+ let repoUrl = lastRev ? `${baseUrl}&since=${encodeURIComponent(lastRev)}` : baseUrl;
172
+ let res = await fetch(repoUrl, { signal: controller.signal });
173
+ // If the PDS rejected our `since` rev (compacted history), fall back to full import
174
+ if (res.status === 400 && lastRev) {
175
+ lastRev = null;
176
+ res = await fetch(baseUrl, { signal: controller.signal });
177
+ }
143
178
  if (!res.ok) {
144
179
  const httpErr = new Error(`getRepo failed for ${did}: ${res.status}`);
145
180
  httpErr.httpStatus = res.status;
146
181
  throw httpErr;
147
182
  }
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]);
183
+ resolvedSince = lastRev;
184
+ let { roots, blocks, byteLength } = await parseCarStream(res.body);
185
+ carSizeBytes = byteLength;
186
+ // Decode commit to get MST root — if the diff CAR is missing the root block,
187
+ // fall back to a full import (the PDS compacted past our `since` rev)
188
+ let rootData = blocks.get(roots[0]);
189
+ if (!rootData && lastRev) {
190
+ lastRev = null;
191
+ resolvedSince = null;
192
+ res = await fetch(baseUrl, { signal: controller.signal });
193
+ if (!res.ok) {
194
+ const httpErr = new Error(`getRepo failed for ${did}: ${res.status}`);
195
+ httpErr.httpStatus = res.status;
196
+ throw httpErr;
197
+ }
198
+ ;
199
+ ({ roots, blocks, byteLength } = await parseCarStream(res.body));
200
+ carSizeBytes = byteLength;
201
+ rootData = blocks.get(roots[0]);
202
+ }
154
203
  if (!rootData)
155
204
  throw new Error(`No root block for ${did}`);
156
205
  const { value: commit } = cborDecode(rootData);
157
206
  // Walk MST to find all record paths
158
207
  const entries = walkMst(blocks, commit.data.$link);
159
208
  // 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);
209
+ // Only on full imports (no since) — diff CARs only contain changes
210
+ if (!lastRev) {
211
+ for (const col of purgeableCollections(collections)) {
212
+ const schema = getSchema(col);
213
+ if (!schema)
214
+ continue;
215
+ await runSQL(`DELETE FROM ${schema.tableName} WHERE did = $1`, [did]);
216
+ for (const child of schema.children) {
217
+ await runSQL(`DELETE FROM ${child.tableName} WHERE parent_did = $1`, [did]);
218
+ }
219
+ for (const union of schema.unions) {
220
+ for (const branch of union.branches) {
221
+ await runSQL(`DELETE FROM ${branch.tableName} WHERE parent_did = $1`, [did]);
222
+ }
171
223
  }
172
224
  }
173
225
  }
174
226
  // Insert records in chunks to limit memory usage
175
227
  const CHUNK_SIZE = 1000;
176
228
  let chunk = [];
229
+ const validationSkips = {};
177
230
  for (const entry of entries) {
178
231
  const collection = entry.path.split('/')[0];
179
- if (!collections.has(collection))
232
+ if (!collections.has(collection) || isPrivateCollection(collection))
180
233
  continue;
181
234
  const blockData = blocks.get(entry.cid);
182
235
  if (!blockData)
183
236
  continue;
237
+ blocks.delete(entry.cid); // free block data as we go
184
238
  try {
185
239
  const { value: record } = cborDecode(blockData);
186
240
  if (!record?.$type)
187
241
  continue;
188
242
  const rkey = entry.path.split('/').slice(1).join('/');
189
243
  const uri = `at://${did}/${collection}/${rkey}`;
244
+ const validationError = validateRecord(getLexiconArray(), collection, record);
245
+ if (validationError) {
246
+ validationSkips[collection] = (validationSkips[collection] || 0) + 1;
247
+ continue;
248
+ }
190
249
  chunk.push({ collection, uri, cid: entry.cid, did, record });
191
250
  if (chunk.length >= CHUNK_SIZE) {
192
251
  count += await bulkInsertRecords(chunk);
@@ -202,10 +261,13 @@ export async function backfillRepo(did, collections, fetchTimeout) {
202
261
  });
203
262
  }
204
263
  }
205
- blocks = null; // free block map
206
264
  if (chunk.length > 0) {
207
265
  count += await bulkInsertRecords(chunk);
208
266
  }
267
+ const totalSkips = Object.values(validationSkips).reduce((a, b) => a + b, 0);
268
+ if (totalSkips > 0) {
269
+ emit('backfill', 'validation_skips', { did, total: totalSkips, by_collection: validationSkips });
270
+ }
209
271
  await setRepoStatus(did, 'active', commit.rev, { handle });
210
272
  return count;
211
273
  }
@@ -237,6 +299,8 @@ export async function backfillRepo(did, collections, fetchTimeout) {
237
299
  error,
238
300
  pds_url: resolvedPds,
239
301
  car_size_bytes: carSizeBytes,
302
+ import_mode: carSizeBytes !== undefined ? (resolvedSince ? 'diff' : 'full') : undefined,
303
+ since_rev: resolvedSince,
240
304
  retry_count: retryCount,
241
305
  retry_after: retryAfter,
242
306
  permanent_failure: retryCount === 999 ? true : undefined,
@@ -339,7 +403,7 @@ export async function runBackfill(opts) {
339
403
  const pending = [];
340
404
  for (const did of dids) {
341
405
  const status = await getRepoStatus(did);
342
- if (status !== 'active') {
406
+ if (backfillEligible(status)) {
343
407
  if (!status)
344
408
  await setRepoStatus(did, 'pending');
345
409
  pending.push(did);
@@ -362,7 +426,7 @@ export async function runBackfill(opts) {
362
426
  parallelism: config.parallelism,
363
427
  status: 'success',
364
428
  });
365
- return;
429
+ return 0;
366
430
  }
367
431
  // 3. Backfill with worker pool
368
432
  let totalRecords = 0;
@@ -386,7 +450,7 @@ export async function runBackfill(opts) {
386
450
  retryRound++;
387
451
  // Wait until the earliest retry_after has passed
388
452
  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]);
453
+ 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
454
  const earliest = rows[0]?.earliest ? Number(rows[0].earliest) : 0;
391
455
  if (earliest > now) {
392
456
  await new Promise((resolve) => setTimeout(resolve, (earliest - now) * 1000));
@@ -420,4 +484,5 @@ export async function runBackfill(opts) {
420
484
  retry_rounds: retryRound,
421
485
  status: failedCount > 0 ? 'partial' : 'success',
422
486
  });
487
+ return totalRecords;
423
488
  }
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"}