@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
package/dist/indexer.js CHANGED
@@ -1,24 +1,39 @@
1
1
  import { cborDecode } from "./cbor.js";
2
2
  import { parseCarFrame } from "./car.js";
3
- import { insertRecord, deleteRecord, setCursor, setRepoStatus, getRepoRetryInfo, listAllRepoStatuses } from "./db.js";
3
+ import { isPrivateCollection } from "./private-collections.js";
4
+ import { insertRecord, deleteRecord, setCursor, setRepoStatus, getRepoRetryInfo, listAllRepoStatuses, getDatabasePort, updateRepoHandle, } from "./database/db.js";
4
5
  import { backfillRepo } from "./backfill.js";
5
- import { rebuildAllIndexes } from "./fts.js";
6
+ import { rebuildAllIndexes } from "./database/fts.js";
6
7
  import { log, emit, timer } from "./logger.js";
7
8
  import { runLabelRules } from "./labels.js";
8
- import { getLexiconArray } from "./schema.js";
9
+ import { fireOnCommitHooks } from "./hooks.js";
10
+ import { getLexiconArray } from "./database/schema.js";
9
11
  import { validateRecord } from '@bigmoves/lexicon';
10
12
  let buffer = [];
11
13
  let flushTimer = null;
12
14
  let lastSeq = null;
15
+ let lastPersistedSeq = null;
16
+ let cursorCheckpointTimer = null;
17
+ /**
18
+ * Which `_cursor` row this process persists its stream position to.
19
+ *
20
+ * The relay's `subscribeRepos` seq and Jetstream's seq are different
21
+ * coordinate systems — resuming one from the other's value would either skip
22
+ * a swathe of the stream or replay from a nonsense offset. Each source owns
23
+ * its own key so switching between them (or back) is safe.
24
+ */
25
+ let cursorKey = 'relay';
13
26
  const BATCH_SIZE = 100;
14
27
  const FLUSH_INTERVAL_MS = 500;
28
+ const CURSOR_CHECKPOINT_INTERVAL_MS = 5_000;
15
29
  let writesSinceRebuild = 0;
16
30
  let ftsRebuildInterval = 500;
17
31
  // Event buffer for DIDs mid-backfill
18
32
  const pendingBuffers = new Map();
19
33
  // Track in-flight backfills to avoid duplicates
20
34
  const backfillInFlight = new Set();
21
- const MAX_CONCURRENT_BACKFILLS = 5;
35
+ const backfillPromises = new Map();
36
+ const pendingReschedule = new Set();
22
37
  // In-memory cache of repo status to avoid flooding the DB read queue
23
38
  const repoStatusCache = new Map();
24
39
  // Set by startIndexer
@@ -27,6 +42,13 @@ let indexerSignalCollections;
27
42
  let indexerPinnedRepos = null;
28
43
  let indexerFetchTimeout;
29
44
  let indexerMaxRetries;
45
+ let indexerPlcUrl;
46
+ let maxConcurrentBackfills = 3;
47
+ /**
48
+ * Flush the write buffer — insert all buffered records, update the relay cursor,
49
+ * run label rules on inserted records, and trigger FTS rebuilds when the write
50
+ * threshold is reached. Emits a wide event with batch stats.
51
+ */
30
52
  async function flushBuffer() {
31
53
  if (buffer.length === 0)
32
54
  return;
@@ -47,8 +69,10 @@ async function flushBuffer() {
47
69
  }
48
70
  }
49
71
  if (lastSeq !== null) {
72
+ const seq = lastSeq;
50
73
  try {
51
- await setCursor('relay', String(lastSeq));
74
+ await setCursor(cursorKey, String(seq));
75
+ lastPersistedSeq = seq;
52
76
  }
53
77
  catch (err) {
54
78
  cursorError = err.message;
@@ -64,6 +88,14 @@ async function flushBuffer() {
64
88
  value: item.record,
65
89
  }).catch(() => { });
66
90
  }
91
+ // Fire on-commit hooks for inserted records (async, non-blocking)
92
+ fireOnCommitHooks(inserted.map((item) => ({
93
+ action: 'create',
94
+ collection: item.collection,
95
+ uri: item.uri,
96
+ authorDid: item.authorDid,
97
+ record: item.record,
98
+ })));
67
99
  // Aggregate collection counts and unique DIDs for wide event
68
100
  const collections = {};
69
101
  const dids = new Set();
@@ -86,9 +118,14 @@ async function flushBuffer() {
86
118
  writesSinceRebuild += batch.length;
87
119
  if (writesSinceRebuild >= ftsRebuildInterval) {
88
120
  writesSinceRebuild = 0;
89
- rebuildAllIndexes([...indexerCollections]).catch(() => { });
121
+ // Skip periodic full rebuild for SQLite — it uses incremental FTS updates
122
+ const port = getDatabasePort();
123
+ if (port.dialect !== 'sqlite') {
124
+ rebuildAllIndexes([...indexerCollections]).catch(() => { });
125
+ }
90
126
  }
91
127
  }
128
+ /** Schedule a flush after FLUSH_INTERVAL_MS if one isn't already pending. */
92
129
  function scheduleFlush() {
93
130
  if (flushTimer)
94
131
  return;
@@ -97,6 +134,7 @@ function scheduleFlush() {
97
134
  await flushBuffer();
98
135
  }, FLUSH_INTERVAL_MS);
99
136
  }
137
+ /** Add a record to the write buffer. Flushes immediately if BATCH_SIZE is reached. */
100
138
  function bufferWrite(item) {
101
139
  buffer.push(item);
102
140
  if (buffer.length >= BATCH_SIZE) {
@@ -110,11 +148,100 @@ function bufferWrite(item) {
110
148
  scheduleFlush();
111
149
  }
112
150
  }
151
+ /** Record the latest firehose sequence seen. Source of truth for cursor persistence and reconnect resume. */
152
+ export function noteSeq(seq) {
153
+ lastSeq = seq;
154
+ }
155
+ /** The highest seq this process has seen, or null before the first event. */
156
+ export function getLastSeq() {
157
+ return lastSeq;
158
+ }
159
+ /**
160
+ * Persist the latest firehose sequence when it has advanced past the stored
161
+ * cursor. Runs on a timer independent of the write path: an app whose
162
+ * collections rarely appear on the firehose never flushes a batch, so the
163
+ * flush-path cursor write alone leaves the stored cursor frozen — and a frozen
164
+ * cursor makes the relay replay its whole retention window at line rate
165
+ * (~135 GB/hour observed against bsky.network) on every boot and reconnect.
166
+ */
167
+ export async function checkpointCursor() {
168
+ if (lastSeq === null || lastSeq === lastPersistedSeq)
169
+ return;
170
+ const seq = lastSeq;
171
+ try {
172
+ await setCursor(cursorKey, String(seq));
173
+ lastPersistedSeq = seq;
174
+ }
175
+ catch (err) {
176
+ emit('indexer', 'cursor_checkpoint_error', { cursor_seq: seq, error: err.message });
177
+ }
178
+ }
179
+ /** Point cursor persistence at a stream's own `_cursor` row. See {@link cursorKey}. */
180
+ export function setCursorKey(key) {
181
+ cursorKey = key;
182
+ }
183
+ /**
184
+ * The cursor a reconnect should resume from: the latest seq this process has
185
+ * seen, falling back to the boot-time cursor before any message has arrived.
186
+ * Reusing the boot-time cursor on every reconnect would replay everything
187
+ * received since the process started.
188
+ */
189
+ export function resumeCursor(liveSeq, bootCursor) {
190
+ return liveSeq !== null ? String(liveSeq) : bootCursor;
191
+ }
192
+ /** Reset module cursor state between tests. */
193
+ export function _resetCursorStateForTests() {
194
+ lastSeq = null;
195
+ lastPersistedSeq = null;
196
+ cursorKey = 'relay';
197
+ }
198
+ /**
199
+ * Drain the write buffer synchronously instead of waiting out
200
+ * FLUSH_INTERVAL_MS. Lets end-to-end tests assert on rows immediately after
201
+ * feeding a frame.
202
+ */
203
+ export async function _flushForTests() {
204
+ if (flushTimer) {
205
+ clearTimeout(flushTimer);
206
+ flushTimer = null;
207
+ }
208
+ await flushBuffer();
209
+ }
210
+ /**
211
+ * Auto-backfill a DID's repo when first seen on the firehose.
212
+ *
213
+ * Fetches the full repo via CAR export, inserts all records, then replays any
214
+ * firehose events that arrived during the backfill. Concurrency is capped at
215
+ * `maxConcurrentBackfills`. Failed backfills retry with exponential delay up
216
+ * to `maxRetries`.
217
+ */
218
+ /** Wait for a DID's backfill to complete if one is in flight. */
219
+ export function awaitBackfill(did) {
220
+ const entry = backfillPromises.get(did);
221
+ return entry ? entry.promise : Promise.resolve();
222
+ }
113
223
  export async function triggerAutoBackfill(did, attempt = 0) {
114
224
  if (backfillInFlight.has(did))
115
225
  return;
226
+ if (backfillInFlight.size >= maxConcurrentBackfills) {
227
+ if (!pendingReschedule.has(did)) {
228
+ pendingReschedule.add(did);
229
+ setTimeout(() => {
230
+ pendingReschedule.delete(did);
231
+ triggerAutoBackfill(did, attempt);
232
+ }, 10_000);
233
+ }
234
+ return;
235
+ }
116
236
  backfillInFlight.add(did);
117
237
  pendingBuffers.set(did, []);
238
+ if (!backfillPromises.has(did)) {
239
+ let resolveBackfill;
240
+ const promise = new Promise((r) => {
241
+ resolveBackfill = r;
242
+ });
243
+ backfillPromises.set(did, { promise, resolve: resolveBackfill });
244
+ }
118
245
  if (attempt === 0)
119
246
  await setRepoStatus(did, 'pending');
120
247
  const elapsed = timer();
@@ -154,6 +281,12 @@ export async function triggerAutoBackfill(did, attempt = 0) {
154
281
  error,
155
282
  retry_count: currentRetryCount,
156
283
  });
284
+ // Resolve awaiting callers (e.g. on-login hooks)
285
+ const entry = backfillPromises.get(did);
286
+ if (entry) {
287
+ entry.resolve();
288
+ backfillPromises.delete(did);
289
+ }
157
290
  if (status === 'error' && currentRetryCount < indexerMaxRetries) {
158
291
  const delaySecs = Math.min(currentRetryCount * 60, 3600);
159
292
  const delayMs = Math.max(delaySecs, 60) * 1000;
@@ -162,7 +295,7 @@ export async function triggerAutoBackfill(did, attempt = 0) {
162
295
  }, delayMs);
163
296
  }
164
297
  }
165
- // Periodic memory diagnostics
298
+ /** Emit a memory diagnostics wide event every 30s for observability. */
166
299
  function startMemoryDiagnostics() {
167
300
  setInterval(() => {
168
301
  const mem = process.memoryUsage();
@@ -184,15 +317,26 @@ function startMemoryDiagnostics() {
184
317
  });
185
318
  }, 30_000);
186
319
  }
187
- export async function startIndexer(opts) {
188
- const { relayUrl, collections, cursor, fetchTimeout } = opts;
320
+ /**
321
+ * Connect to the AT Protocol relay firehose and begin indexing.
322
+ *
323
+ * Opens a WebSocket to `subscribeRepos`, processes commit messages synchronously
324
+ * on the event loop to minimize backpressure, and batches writes through
325
+ * {@link flushBuffer}. New DIDs trigger auto-backfill via {@link triggerAutoBackfill}.
326
+ * Reconnects automatically on disconnect after a 3s delay.
327
+ *
328
+ * @returns The WebSocket connection (for shutdown coordination)
329
+ */
330
+ export async function configureIndexer(opts) {
189
331
  if (opts.ftsRebuildInterval != null)
190
332
  ftsRebuildInterval = opts.ftsRebuildInterval;
191
- indexerCollections = collections;
192
- indexerSignalCollections = opts.signalCollections || collections;
333
+ indexerCollections = opts.collections;
334
+ indexerSignalCollections = opts.signalCollections || opts.collections;
193
335
  indexerPinnedRepos = opts.pinnedRepos || null;
194
- indexerFetchTimeout = fetchTimeout;
336
+ indexerFetchTimeout = opts.fetchTimeout;
195
337
  indexerMaxRetries = opts.maxRetries;
338
+ indexerPlcUrl = opts.plcUrl;
339
+ maxConcurrentBackfills = opts.parallelism ?? 3;
196
340
  // Pre-populate repo status cache from DB so non-signal updates
197
341
  // (e.g. profile changes) are processed for already-tracked DIDs
198
342
  if (repoStatusCache.size === 0) {
@@ -202,7 +346,19 @@ export async function startIndexer(opts) {
202
346
  }
203
347
  log(`[indexer] Warmed repo status cache with ${statuses.length} entries`);
204
348
  }
205
- startMemoryDiagnostics();
349
+ // startMemoryDiagnostics()
350
+ // Checkpoint the cursor on a timer regardless of write activity (see
351
+ // checkpointCursor). Guarded so reconnects don't stack intervals; unref'd so
352
+ // it never keeps the process alive.
353
+ if (!cursorCheckpointTimer) {
354
+ cursorCheckpointTimer = setInterval(() => void checkpointCursor(), CURSOR_CHECKPOINT_INTERVAL_MS);
355
+ cursorCheckpointTimer.unref?.();
356
+ }
357
+ }
358
+ export async function startIndexer(opts) {
359
+ const { relayUrl, collections, cursor } = opts;
360
+ setCursorKey('relay');
361
+ await configureIndexer(opts);
206
362
  let wsUrl = `${relayUrl}/xrpc/com.atproto.sync.subscribeRepos`;
207
363
  if (cursor) {
208
364
  wsUrl += `?cursor=${cursor}`;
@@ -220,41 +376,167 @@ export async function startIndexer(opts) {
220
376
  const bytes = new Uint8Array(event.data);
221
377
  processMessage(bytes, collections);
222
378
  }
223
- catch {
224
- // Skip unparseable firehose messages silently
379
+ catch (err) {
380
+ emit('indexer', 'decode_error', { error: err instanceof Error ? err.message : String(err) });
225
381
  }
226
382
  });
227
383
  ws.addEventListener('open', () => log('[indexer] Connected to relay'));
228
384
  ws.addEventListener('close', () => {
229
385
  log('[indexer] Disconnected, reconnecting in 3s...');
230
- setTimeout(() => startIndexer(opts), 3000);
386
+ setTimeout(() => startIndexer({ ...opts, cursor: resumeCursor(lastSeq, opts.cursor) }), 3000);
231
387
  });
232
388
  return ws;
233
389
  }
234
- function processMessage(bytes, collections) {
235
- const header = cborDecode(bytes, 0);
236
- const body = cborDecode(bytes, header.offset);
237
- if (header.value.op !== 1 || header.value.t !== '#commit')
390
+ /** The `_cursor` row an auxiliary firehose persists its position to. */
391
+ export function auxCursorKey(relayUrl) {
392
+ return `relay:${relayUrl}`;
393
+ }
394
+ /**
395
+ * Tail a second `subscribeRepos` alongside the primary stream.
396
+ *
397
+ * A relay is one coordinate system; a PDS tailed directly is another. Nothing
398
+ * below the wire cares which socket a frame arrived on — `processMessage`
399
+ * decodes it and `applyCommit` indexes it — so the only state an extra source
400
+ * needs of its own is a seq and a cursor row. Both live in this closure,
401
+ * keyed by URL, so an aux stream never advances (or resumes from) the primary
402
+ * cursor. Use this when a repo's PDS is not behind the relay being tailed —
403
+ * a self-hosted network, a dev stack with more than one PDS — rather than
404
+ * standing up a relay just to merge two streams.
405
+ *
406
+ * Must be called after {@link configureIndexer} (or {@link startIndexer}),
407
+ * which owns the shared indexer configuration.
408
+ */
409
+ export function startAuxIndexer(opts) {
410
+ const { relayUrl, collections } = opts;
411
+ const cursorKey = auxCursorKey(relayUrl);
412
+ let seq = null;
413
+ let persistedSeq = null;
414
+ const checkpoint = async () => {
415
+ if (seq === null || seq === persistedSeq)
416
+ return;
417
+ const s = seq;
418
+ try {
419
+ await setCursor(cursorKey, String(s));
420
+ persistedSeq = s;
421
+ }
422
+ catch (err) {
423
+ emit('indexer', 'cursor_checkpoint_error', { source: relayUrl, cursor_seq: s, error: err.message });
424
+ }
425
+ };
426
+ const timer = setInterval(() => void checkpoint(), CURSOR_CHECKPOINT_INTERVAL_MS);
427
+ timer.unref?.();
428
+ const connect = (cursor) => {
429
+ let wsUrl = `${relayUrl}/xrpc/com.atproto.sync.subscribeRepos`;
430
+ if (cursor) {
431
+ wsUrl += `?cursor=${cursor}`;
432
+ log(`[indexer:aux] Resuming ${relayUrl} from cursor ${cursor}`);
433
+ }
434
+ log(`[indexer:aux] Connecting to ${relayUrl}...`);
435
+ const ws = new WebSocket(wsUrl);
436
+ ws.binaryType = 'arraybuffer';
437
+ ws.addEventListener('message', (event) => {
438
+ try {
439
+ if (!(event.data instanceof ArrayBuffer))
440
+ return;
441
+ processMessage(new Uint8Array(event.data), collections, (s) => {
442
+ seq = s;
443
+ });
444
+ }
445
+ catch (err) {
446
+ emit('indexer', 'decode_error', { source: relayUrl, error: err instanceof Error ? err.message : String(err) });
447
+ }
448
+ });
449
+ ws.addEventListener('open', () => log(`[indexer:aux] Connected to ${relayUrl}`));
450
+ ws.addEventListener('close', () => {
451
+ log(`[indexer:aux] Disconnected from ${relayUrl}, reconnecting in 3s...`);
452
+ setTimeout(() => connect(resumeCursor(seq, opts.cursor)), 3000);
453
+ });
454
+ return ws;
455
+ };
456
+ return connect(opts.cursor);
457
+ }
458
+ /**
459
+ * Handle a `#identity` firehose event for a DID. The `handle` field on the
460
+ * event is optional per the lexicon, and some emitters omit it (signalling
461
+ * "re-resolve"). When absent, we re-resolve from the PLC directory so handle
462
+ * renames propagate even when the relay payload is sparse.
463
+ *
464
+ * Only updates DIDs we already track (present in repoStatusCache) to avoid
465
+ * writing rows for the entire network.
466
+ */
467
+ export async function handleIdentityEvent(did, payloadHandle) {
468
+ if (!repoStatusCache.has(did))
238
469
  return;
239
- if (!body.value.blocks || !body.value.ops)
470
+ let handle = payloadHandle;
471
+ const payloadHadHandle = handle !== undefined;
472
+ if (!handle) {
473
+ try {
474
+ // Bound the PLC fetch so a slow plc.directory can't pile up unbounded
475
+ // promises during an identity-event burst (fire-and-forget caller).
476
+ const res = await fetch(`${indexerPlcUrl}/${did}`, {
477
+ signal: AbortSignal.timeout(indexerFetchTimeout * 1000),
478
+ });
479
+ if (res.ok) {
480
+ const doc = (await res.json());
481
+ // First at:// entry is the canonical handle (per @atproto/identity convention)
482
+ const aka = doc.alsoKnownAs?.find((u) => u.startsWith('at://'));
483
+ handle = aka ? aka.slice('at://'.length) : undefined;
484
+ }
485
+ else {
486
+ emit('indexer', 'identity_resolve_error', { did, status: res.status });
487
+ }
488
+ }
489
+ catch (err) {
490
+ emit('indexer', 'identity_resolve_error', {
491
+ did,
492
+ error: err instanceof Error ? err.message : String(err),
493
+ });
494
+ }
495
+ }
496
+ if (!handle) {
497
+ emit('indexer', 'identity_no_handle', { did, payload_had_handle: payloadHadHandle });
240
498
  return;
241
- // Track sequence number for cursor
242
- if (body.value.seq)
243
- lastSeq = body.value.seq;
244
- const did = body.value.repo;
245
- if (!did)
499
+ }
500
+ try {
501
+ await updateRepoHandle(did, handle);
502
+ emit('indexer', 'identity_handle_update', { did, handle, payload_had_handle: payloadHadHandle });
503
+ }
504
+ catch (err) {
505
+ emit('indexer', 'identity_update_error', {
506
+ did,
507
+ handle,
508
+ error: err instanceof Error ? err.message : String(err),
509
+ });
510
+ }
511
+ }
512
+ /**
513
+ * Whether a collection's records may be written from network data.
514
+ *
515
+ * Private collections are AppView-authoritative: nothing on the network may
516
+ * write or delete their rows, so stream ops naming them are spoofed by
517
+ * definition and must be dropped regardless of which source delivered them.
518
+ */
519
+ export function isIndexableCollection(collection, collections) {
520
+ return collections.has(collection) && !isPrivateCollection(collection);
521
+ }
522
+ /**
523
+ * Apply a repo's commit ops to the index.
524
+ *
525
+ * Wire-agnostic: the relay path reaches here after CBOR/CAR decoding, the
526
+ * Jetstream path after parsing JSON. Callers must have already filtered `ops`
527
+ * through {@link isIndexableCollection}.
528
+ *
529
+ * Handles auto-backfill signalling, buffering for DIDs mid-backfill, lexicon
530
+ * validation, and delete/put routing.
531
+ */
532
+ export function applyCommit(did, ops) {
533
+ if (ops.length === 0)
246
534
  return;
247
535
  // When repos are pinned, only process events from those DIDs
248
536
  if (indexerPinnedRepos && !indexerPinnedRepos.has(did))
249
537
  return;
250
- // Check if any ops in this commit are for collections we care about
251
- const relevantOps = body.value.ops.filter((op) => collections.has(op.path.split('/')[0]));
252
- if (relevantOps.length === 0)
253
- return;
254
- // Copy blocks out of the original buffer before it can be GC'd
255
- const { blocks } = parseCarFrame(new Uint8Array(body.value.blocks));
256
538
  // Only auto-backfill when we see activity in a signal collection
257
- const hasSignalOp = relevantOps.some((op) => indexerSignalCollections.has(op.path.split('/')[0]));
539
+ const hasSignalOp = ops.some((op) => indexerSignalCollections.has(op.collection));
258
540
  // Use in-memory cache only — never hit DB from the hot path.
259
541
  // Unknown DIDs stay unknown until backfill or auto-backfill discovers them.
260
542
  // The cache is populated by triggerAutoBackfill and setRepoStatus calls.
@@ -263,8 +545,8 @@ function processMessage(bytes, collections) {
263
545
  if (cachedStatus === undefined) {
264
546
  repoStatusCache.set(did, 'unknown');
265
547
  }
266
- if (hasSignalOp && (!indexerPinnedRepos || indexerPinnedRepos.has(did))) {
267
- if (repoStatus === null && backfillInFlight.size < MAX_CONCURRENT_BACKFILLS) {
548
+ if (hasSignalOp) {
549
+ if (repoStatus === null && backfillInFlight.size < maxConcurrentBackfills) {
268
550
  repoStatusCache.set(did, 'pending');
269
551
  triggerAutoBackfill(did);
270
552
  }
@@ -274,43 +556,101 @@ function processMessage(bytes, collections) {
274
556
  }
275
557
  }
276
558
  // For non-signal ops (e.g. profile updates), only process if this DID is already tracked
277
- if (!hasSignalOp) {
278
- if (repoStatus === null)
279
- return;
559
+ if (!hasSignalOp && repoStatus === null)
560
+ return;
561
+ for (const op of ops) {
562
+ const uri = `at://${did}/${op.collection}/${op.rkey}`;
563
+ if (op.action === 'delete') {
564
+ deleteRecord(op.collection, uri);
565
+ fireOnCommitHooks([
566
+ {
567
+ action: 'delete',
568
+ collection: op.collection,
569
+ uri,
570
+ authorDid: did,
571
+ record: null,
572
+ },
573
+ ]);
574
+ continue;
575
+ }
576
+ const record = op.record;
577
+ if (!op.cid || !record)
578
+ continue;
579
+ if (record.$type !== op.collection)
580
+ continue;
581
+ const validationError = validateRecord(getLexiconArray(), op.collection, record);
582
+ if (validationError) {
583
+ emit('indexer', 'validation_skip', {
584
+ uri,
585
+ collection: op.collection,
586
+ path: validationError.path,
587
+ error: validationError.message,
588
+ });
589
+ continue;
590
+ }
591
+ const item = { collection: op.collection, uri, cid: op.cid, authorDid: did, record };
592
+ // If DID is mid-backfill, buffer instead of writing directly
593
+ if (pendingBuffers.has(did)) {
594
+ pendingBuffers.get(did).push(item);
595
+ }
596
+ else {
597
+ bufferWrite(item);
598
+ }
599
+ }
600
+ }
601
+ /**
602
+ * Process a single firehose message. Decodes the CBOR header/body, filters
603
+ * for relevant collections, validates records against lexicons, and routes
604
+ * writes to the buffer (or pending buffer if the DID is mid-backfill).
605
+ */
606
+ export function processMessage(bytes, collections, onSeq = noteSeq) {
607
+ const header = cborDecode(bytes, 0);
608
+ const body = cborDecode(bytes, header.offset);
609
+ // Handle identity events (handle changes). Fire-and-forget — keeps
610
+ // processMessage synchronous so the WS event loop drains without backpressure.
611
+ if (header.value.t === '#identity') {
612
+ const did = typeof body.value.did === 'string' ? body.value.did : undefined;
613
+ const handle = typeof body.value.handle === 'string' ? body.value.handle : undefined;
614
+ if (did)
615
+ handleIdentityEvent(did, handle);
616
+ return;
280
617
  }
618
+ if (header.value.op !== 1 || header.value.t !== '#commit')
619
+ return;
620
+ if (!body.value.blocks || !body.value.ops)
621
+ return;
622
+ // Track sequence number for cursor
623
+ if (body.value.seq)
624
+ onSeq(body.value.seq);
625
+ const did = body.value.repo;
626
+ if (!did)
627
+ return;
628
+ // Check if any ops in this commit are for collections we care about, before
629
+ // paying for the CAR parse below.
630
+ const relevantOps = body.value.ops.filter((op) => isIndexableCollection(op.path.split('/')[0], collections));
631
+ if (relevantOps.length === 0)
632
+ return;
633
+ // Copy blocks out of the original buffer before it can be GC'd
634
+ const { blocks } = parseCarFrame(new Uint8Array(body.value.blocks));
635
+ const ops = [];
281
636
  for (const op of relevantOps) {
282
637
  const collection = op.path.split('/')[0];
283
- const uri = `at://${did}/${op.path}`;
638
+ const rkey = op.path.split('/').slice(1).join('/');
284
639
  if (op.action === 'delete') {
285
- deleteRecord(collection, uri);
640
+ ops.push({ action: 'delete', collection, rkey });
286
641
  continue;
287
642
  }
288
- for (const [cid, data] of blocks) {
289
- try {
290
- const { value: record } = cborDecode(data);
291
- if (record?.$type === collection) {
292
- const validationError = validateRecord(getLexiconArray(), collection, record);
293
- if (validationError) {
294
- emit('indexer', 'validation_skip', {
295
- uri,
296
- collection,
297
- path: validationError.path,
298
- error: validationError.message,
299
- });
300
- break;
301
- }
302
- const item = { collection, uri, cid, authorDid: did, record };
303
- // If DID is mid-backfill, buffer instead of writing directly
304
- if (pendingBuffers.has(did)) {
305
- pendingBuffers.get(did).push(item);
306
- }
307
- else {
308
- bufferWrite(item);
309
- }
310
- break;
311
- }
312
- }
313
- catch { }
643
+ const opCid = typeof op.cid === 'string' ? op.cid : op.cid?.$link;
644
+ if (!opCid)
645
+ continue;
646
+ const data = blocks.get(opCid);
647
+ if (!data)
648
+ continue;
649
+ try {
650
+ const { value: record } = cborDecode(data);
651
+ ops.push({ action: op.action === 'update' ? 'update' : 'create', collection, rkey, cid: opCid, record });
314
652
  }
653
+ catch { }
315
654
  }
655
+ applyCommit(did, ops);
316
656
  }