@hatk/hatk 0.0.1-alpha.11 → 0.0.1-alpha.111

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 (224) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +113 -0
  4. package/dist/backfill.d.ts +36 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +153 -37
  7. package/dist/car.js +1 -1
  8. package/dist/classifiers.d.ts +228 -0
  9. package/dist/classifiers.d.ts.map +1 -0
  10. package/dist/classifiers.js +688 -0
  11. package/dist/cli.js +248 -1043
  12. package/dist/config.d.ts +179 -1
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +78 -8
  15. package/dist/database/adapter-factory.d.ts +6 -0
  16. package/dist/database/adapter-factory.d.ts.map +1 -0
  17. package/dist/database/adapter-factory.js +20 -0
  18. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  19. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  20. package/dist/database/adapters/duckdb-search.js +27 -0
  21. package/dist/database/adapters/duckdb.d.ts +25 -0
  22. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  23. package/dist/database/adapters/duckdb.js +161 -0
  24. package/dist/database/adapters/sqlite-search.d.ts +16 -0
  25. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  26. package/dist/database/adapters/sqlite-search.js +76 -0
  27. package/dist/database/adapters/sqlite.d.ts +18 -0
  28. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  29. package/dist/database/adapters/sqlite.js +88 -0
  30. package/dist/database/db.d.ts +290 -0
  31. package/dist/database/db.d.ts.map +1 -0
  32. package/dist/database/db.js +2073 -0
  33. package/dist/database/dialect.d.ts +45 -0
  34. package/dist/database/dialect.d.ts.map +1 -0
  35. package/dist/database/dialect.js +72 -0
  36. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  37. package/dist/database/fts.d.ts.map +1 -0
  38. package/dist/{fts.js → database/fts.js} +122 -34
  39. package/dist/database/index.d.ts +7 -0
  40. package/dist/database/index.d.ts.map +1 -0
  41. package/dist/database/index.js +6 -0
  42. package/dist/database/ports.d.ts +50 -0
  43. package/dist/database/ports.d.ts.map +1 -0
  44. package/dist/database/ports.js +1 -0
  45. package/dist/database/schema.d.ts +91 -0
  46. package/dist/database/schema.d.ts.map +1 -0
  47. package/dist/{schema.js → database/schema.js} +153 -54
  48. package/dist/dev-entry.d.ts +8 -0
  49. package/dist/dev-entry.d.ts.map +1 -0
  50. package/dist/dev-entry.js +131 -0
  51. package/dist/feeds.d.ts +26 -8
  52. package/dist/feeds.d.ts.map +1 -1
  53. package/dist/feeds.js +72 -16
  54. package/dist/hooks.d.ts +102 -0
  55. package/dist/hooks.d.ts.map +1 -0
  56. package/dist/hooks.js +186 -0
  57. package/dist/hydrate.d.ts +55 -6
  58. package/dist/hydrate.d.ts.map +1 -1
  59. package/dist/hydrate.js +21 -20
  60. package/dist/indexer.d.ts +194 -3
  61. package/dist/indexer.d.ts.map +1 -1
  62. package/dist/indexer.js +627 -92
  63. package/dist/jetstream.d.ts +118 -0
  64. package/dist/jetstream.d.ts.map +1 -0
  65. package/dist/jetstream.js +238 -0
  66. package/dist/labels.d.ts +36 -0
  67. package/dist/labels.d.ts.map +1 -1
  68. package/dist/labels.js +71 -6
  69. package/dist/lex-types.d.ts +2 -0
  70. package/dist/lex-types.d.ts.map +1 -1
  71. package/dist/lexicon-resolve.d.ts.map +1 -1
  72. package/dist/lexicon-resolve.js +27 -112
  73. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  74. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  75. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  76. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  77. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  78. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  79. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  80. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  81. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  82. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  83. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  84. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  85. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  86. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  87. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  88. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  89. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  90. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  91. package/dist/logger.d.ts +29 -0
  92. package/dist/logger.d.ts.map +1 -1
  93. package/dist/logger.js +29 -0
  94. package/dist/main.js +212 -75
  95. package/dist/mst.d.ts +15 -0
  96. package/dist/mst.d.ts.map +1 -1
  97. package/dist/mst.js +13 -0
  98. package/dist/oauth/db.d.ts +27 -1
  99. package/dist/oauth/db.d.ts.map +1 -1
  100. package/dist/oauth/db.js +80 -19
  101. package/dist/oauth/discovery.d.ts +10 -0
  102. package/dist/oauth/discovery.d.ts.map +1 -1
  103. package/dist/oauth/discovery.js +54 -2
  104. package/dist/oauth/server.d.ts +131 -1
  105. package/dist/oauth/server.d.ts.map +1 -1
  106. package/dist/oauth/server.js +504 -39
  107. package/dist/oauth/service-describe.d.ts +42 -0
  108. package/dist/oauth/service-describe.d.ts.map +1 -0
  109. package/dist/oauth/service-describe.js +108 -0
  110. package/dist/oauth/session.d.ts +32 -0
  111. package/dist/oauth/session.d.ts.map +1 -0
  112. package/dist/oauth/session.js +131 -0
  113. package/dist/opengraph.d.ts +10 -0
  114. package/dist/opengraph.d.ts.map +1 -1
  115. package/dist/opengraph.js +94 -42
  116. package/dist/pds-proxy.d.ts +92 -0
  117. package/dist/pds-proxy.d.ts.map +1 -0
  118. package/dist/pds-proxy.js +396 -0
  119. package/dist/private-collections.d.ts +13 -0
  120. package/dist/private-collections.d.ts.map +1 -0
  121. package/dist/private-collections.js +27 -0
  122. package/dist/push.d.ts +54 -0
  123. package/dist/push.d.ts.map +1 -0
  124. package/dist/push.js +367 -0
  125. package/dist/renderer.d.ts +27 -0
  126. package/dist/renderer.d.ts.map +1 -0
  127. package/dist/renderer.js +46 -0
  128. package/dist/resolve-hatk.d.ts +6 -0
  129. package/dist/resolve-hatk.d.ts.map +1 -0
  130. package/dist/resolve-hatk.js +20 -0
  131. package/dist/response.d.ts +16 -0
  132. package/dist/response.d.ts.map +1 -0
  133. package/dist/response.js +69 -0
  134. package/dist/scanner.d.ts +22 -0
  135. package/dist/scanner.d.ts.map +1 -0
  136. package/dist/scanner.js +92 -0
  137. package/dist/seed.d.ts +19 -0
  138. package/dist/seed.d.ts.map +1 -1
  139. package/dist/seed.js +43 -4
  140. package/dist/server-init.d.ts +8 -0
  141. package/dist/server-init.d.ts.map +1 -0
  142. package/dist/server-init.js +75 -0
  143. package/dist/server.d.ts +46 -3
  144. package/dist/server.d.ts.map +1 -1
  145. package/dist/server.js +1137 -649
  146. package/dist/setup.d.ts +28 -1
  147. package/dist/setup.d.ts.map +1 -1
  148. package/dist/setup.js +50 -3
  149. package/dist/spaces/blob.d.ts +112 -0
  150. package/dist/spaces/blob.d.ts.map +1 -0
  151. package/dist/spaces/blob.js +460 -0
  152. package/dist/spaces/credential.d.ts +82 -0
  153. package/dist/spaces/credential.d.ts.map +1 -0
  154. package/dist/spaces/credential.js +220 -0
  155. package/dist/spaces/engine.d.ts +97 -0
  156. package/dist/spaces/engine.d.ts.map +1 -0
  157. package/dist/spaces/engine.js +563 -0
  158. package/dist/spaces/guard.d.ts +41 -0
  159. package/dist/spaces/guard.d.ts.map +1 -0
  160. package/dist/spaces/guard.js +79 -0
  161. package/dist/spaces/identity.d.ts +37 -0
  162. package/dist/spaces/identity.d.ts.map +1 -0
  163. package/dist/spaces/identity.js +93 -0
  164. package/dist/spaces/index.d.ts +56 -0
  165. package/dist/spaces/index.d.ts.map +1 -0
  166. package/dist/spaces/index.js +134 -0
  167. package/dist/spaces/notify.d.ts +60 -0
  168. package/dist/spaces/notify.d.ts.map +1 -0
  169. package/dist/spaces/notify.js +128 -0
  170. package/dist/spaces/records.d.ts +37 -0
  171. package/dist/spaces/records.d.ts.map +1 -0
  172. package/dist/spaces/records.js +63 -0
  173. package/dist/spaces/signature.d.ts +36 -0
  174. package/dist/spaces/signature.d.ts.map +1 -0
  175. package/dist/spaces/signature.js +83 -0
  176. package/dist/spaces/store.d.ts +51 -0
  177. package/dist/spaces/store.d.ts.map +1 -0
  178. package/dist/spaces/store.js +99 -0
  179. package/dist/spaces/uri.d.ts +60 -0
  180. package/dist/spaces/uri.d.ts.map +1 -0
  181. package/dist/spaces/uri.js +97 -0
  182. package/dist/spaces/verify.d.ts +46 -0
  183. package/dist/spaces/verify.d.ts.map +1 -0
  184. package/dist/spaces/verify.js +94 -0
  185. package/dist/spaces/viewer.d.ts +51 -0
  186. package/dist/spaces/viewer.d.ts.map +1 -0
  187. package/dist/spaces/viewer.js +167 -0
  188. package/dist/spaces/visibility.d.ts +57 -0
  189. package/dist/spaces/visibility.d.ts.map +1 -0
  190. package/dist/spaces/visibility.js +81 -0
  191. package/dist/templates/feed.tpl +18 -0
  192. package/dist/templates/hook.tpl +5 -0
  193. package/dist/templates/label.tpl +15 -0
  194. package/dist/templates/og.tpl +17 -0
  195. package/dist/templates/seed.tpl +11 -0
  196. package/dist/templates/setup.tpl +5 -0
  197. package/dist/templates/test-feed.tpl +19 -0
  198. package/dist/templates/test-xrpc.tpl +19 -0
  199. package/dist/templates/xrpc.tpl +53 -0
  200. package/dist/test.d.ts +3 -1
  201. package/dist/test.d.ts.map +1 -1
  202. package/dist/test.js +60 -36
  203. package/dist/views.js +1 -1
  204. package/dist/vite-plugin.d.ts +1 -1
  205. package/dist/vite-plugin.d.ts.map +1 -1
  206. package/dist/vite-plugin.js +254 -66
  207. package/dist/xrpc.d.ts +143 -12
  208. package/dist/xrpc.d.ts.map +1 -1
  209. package/dist/xrpc.js +282 -55
  210. package/package.json +29 -9
  211. package/public/admin-auth.js +271 -306
  212. package/public/admin.html +652 -61
  213. package/dist/db.d.ts +0 -135
  214. package/dist/db.d.ts.map +0 -1
  215. package/dist/db.js +0 -1365
  216. package/dist/fts.d.ts.map +0 -1
  217. package/dist/oauth/hooks.d.ts +0 -10
  218. package/dist/oauth/hooks.d.ts.map +0 -1
  219. package/dist/oauth/hooks.js +0 -40
  220. package/dist/schema.d.ts +0 -51
  221. package/dist/schema.d.ts.map +0 -1
  222. package/dist/test-browser.d.ts +0 -14
  223. package/dist/test-browser.d.ts.map +0 -1
  224. package/dist/test-browser.js +0 -26
package/dist/indexer.js CHANGED
@@ -1,23 +1,44 @@
1
+ import { reshapeRow } from "./database/db.js";
2
+ import { unfilteredQuerySQL } from "./spaces/guard.js";
3
+ import { rkeyOf } from "./spaces/uri.js";
1
4
  import { cborDecode } from "./cbor.js";
2
5
  import { parseCarFrame } from "./car.js";
3
- import { insertRecord, deleteRecord, setCursor, setRepoStatus, getRepoRetryInfo, listAllRepoStatuses } from "./db.js";
6
+ import { isPrivateCollection } from "./private-collections.js";
7
+ import { insertRecord, deleteRecord, setCursor, setRepoStatus, getRepoStatus, purgeRepoRecords, getRepoRetryInfo, listAllRepoStatuses, getDatabasePort, updateRepoHandle, } from "./database/db.js";
4
8
  import { backfillRepo } from "./backfill.js";
5
- import { rebuildAllIndexes } from "./fts.js";
9
+ import { rebuildAllIndexes } from "./database/fts.js";
6
10
  import { log, emit, timer } from "./logger.js";
7
11
  import { runLabelRules } from "./labels.js";
8
- import { getLexiconArray } from "./schema.js";
12
+ import { fireOnCommitHooks } from "./hooks.js";
13
+ import { noteIndexed, noteBackfilled } from "./classifiers.js";
14
+ import { getLexiconArray } from "./database/schema.js";
9
15
  import { validateRecord } from '@bigmoves/lexicon';
10
16
  let buffer = [];
11
17
  let flushTimer = null;
12
18
  let lastSeq = null;
19
+ let lastPersistedSeq = null;
20
+ let cursorCheckpointTimer = null;
21
+ /**
22
+ * Which `_cursor` row this process persists its stream position to.
23
+ *
24
+ * The relay's `subscribeRepos` seq and Jetstream's seq are different
25
+ * coordinate systems — resuming one from the other's value would either skip
26
+ * a swathe of the stream or replay from a nonsense offset. So is one Jetstream
27
+ * instance's seq against another's, which is why the key names the instance
28
+ * rather than the kind. Each source owns its own key so switching between them
29
+ * (or back) is safe.
30
+ */
31
+ let cursorKey = 'relay';
13
32
  const BATCH_SIZE = 100;
14
33
  const FLUSH_INTERVAL_MS = 500;
34
+ const CURSOR_CHECKPOINT_INTERVAL_MS = 5_000;
15
35
  let writesSinceRebuild = 0;
16
36
  let ftsRebuildInterval = 500;
17
37
  // Event buffer for DIDs mid-backfill
18
38
  const pendingBuffers = new Map();
19
39
  // Track in-flight backfills to avoid duplicates
20
40
  const backfillInFlight = new Set();
41
+ const backfillPromises = new Map();
21
42
  const pendingReschedule = new Set();
22
43
  // In-memory cache of repo status to avoid flooding the DB read queue
23
44
  const repoStatusCache = new Map();
@@ -27,29 +48,62 @@ let indexerSignalCollections;
27
48
  let indexerPinnedRepos = null;
28
49
  let indexerFetchTimeout;
29
50
  let indexerMaxRetries;
51
+ let indexerPlcUrl;
30
52
  let maxConcurrentBackfills = 3;
53
+ /**
54
+ * Flush the write buffer — apply all buffered puts and deletes in arrival
55
+ * order, update the relay cursor, run label rules on inserted records, and
56
+ * trigger FTS rebuilds when the write threshold is reached. Emits a wide event
57
+ * with batch stats.
58
+ */
31
59
  async function flushBuffer() {
32
60
  if (buffer.length === 0)
33
61
  return;
34
62
  const elapsed = timer();
35
63
  const batch = buffer.splice(0);
36
64
  let insertedCount = 0;
65
+ let deletedCount = 0;
37
66
  const errors = [];
38
67
  let cursorError;
39
68
  const inserted = [];
69
+ const applied = [];
70
+ // Strictly sequential: two writes to the same URI in one batch must land in
71
+ // the order they arrived, so nothing here may run concurrently.
40
72
  for (const item of batch) {
41
73
  try {
42
- await insertRecord(item.collection, item.uri, item.cid, item.authorDid, item.record);
43
- insertedCount++;
44
- inserted.push(item);
74
+ if (item.action === 'delete') {
75
+ await deleteRecord(item.collection, item.uri);
76
+ deletedCount++;
77
+ applied.push({
78
+ action: 'delete',
79
+ collection: item.collection,
80
+ uri: item.uri,
81
+ authorDid: item.authorDid,
82
+ record: null,
83
+ });
84
+ }
85
+ else {
86
+ await insertRecord(item.collection, item.uri, item.cid, item.authorDid, item.record);
87
+ insertedCount++;
88
+ inserted.push(item);
89
+ applied.push({
90
+ action: 'create',
91
+ collection: item.collection,
92
+ uri: item.uri,
93
+ authorDid: item.authorDid,
94
+ record: item.record,
95
+ });
96
+ }
45
97
  }
46
98
  catch (err) {
47
99
  errors.push(err.message);
48
100
  }
49
101
  }
50
102
  if (lastSeq !== null) {
103
+ const seq = lastSeq;
51
104
  try {
52
- await setCursor('relay', String(lastSeq));
105
+ await setCursor(cursorKey, String(seq));
106
+ lastPersistedSeq = seq;
53
107
  }
54
108
  catch (err) {
55
109
  cursorError = err.message;
@@ -65,6 +119,11 @@ async function flushBuffer() {
65
119
  value: item.record,
66
120
  }).catch(() => { });
67
121
  }
122
+ // Fire on-commit hooks for everything the batch applied, in the order it was
123
+ // applied (async, non-blocking)
124
+ fireOnCommitHooks(applied);
125
+ // Queue what is new for the classifiers (non-blocking: never waits on a model)
126
+ noteIndexed(applied);
68
127
  // Aggregate collection counts and unique DIDs for wide event
69
128
  const collections = {};
70
129
  const dids = new Set();
@@ -75,6 +134,7 @@ async function flushBuffer() {
75
134
  emit('indexer', 'flush', {
76
135
  batch_size: batch.length,
77
136
  inserted_count: insertedCount,
137
+ deleted_count: deletedCount,
78
138
  error_count: errors.length,
79
139
  cursor_seq: lastSeq,
80
140
  duration_ms: elapsed(),
@@ -87,17 +147,39 @@ async function flushBuffer() {
87
147
  writesSinceRebuild += batch.length;
88
148
  if (writesSinceRebuild >= ftsRebuildInterval) {
89
149
  writesSinceRebuild = 0;
90
- rebuildAllIndexes([...indexerCollections]).catch(() => { });
150
+ // Skip periodic full rebuild for SQLite — it uses incremental FTS updates
151
+ const port = getDatabasePort();
152
+ if (port.dialect !== 'sqlite') {
153
+ rebuildAllIndexes([...indexerCollections]).catch(() => { });
154
+ }
91
155
  }
92
156
  }
157
+ /**
158
+ * Run a flush once every flush already queued has finished.
159
+ *
160
+ * Nothing awaits the flush a full batch triggers, and the interval timer can
161
+ * fire while that flush is still waiting on the database. Two flushes in flight
162
+ * hold disjoint batches but interleave their awaits, so a write in the later
163
+ * batch can reach the database before one in the earlier batch — which would
164
+ * give back exactly the cross-batch reordering the single buffer removes.
165
+ * Chaining them costs nothing on an idle indexer and keeps the queue global.
166
+ */
167
+ let flushChain = Promise.resolve();
168
+ function enqueueFlush() {
169
+ const next = flushChain.then(() => flushBuffer(), () => flushBuffer());
170
+ flushChain = next;
171
+ return next;
172
+ }
173
+ /** Schedule a flush after FLUSH_INTERVAL_MS if one isn't already pending. */
93
174
  function scheduleFlush() {
94
175
  if (flushTimer)
95
176
  return;
96
- flushTimer = setTimeout(async () => {
177
+ flushTimer = setTimeout(() => {
97
178
  flushTimer = null;
98
- await flushBuffer();
179
+ void enqueueFlush().catch(() => { });
99
180
  }, FLUSH_INTERVAL_MS);
100
181
  }
182
+ /** Add a write to the buffer. Flushes immediately if BATCH_SIZE is reached. */
101
183
  function bufferWrite(item) {
102
184
  buffer.push(item);
103
185
  if (buffer.length >= BATCH_SIZE) {
@@ -105,12 +187,185 @@ function bufferWrite(item) {
105
187
  clearTimeout(flushTimer);
106
188
  flushTimer = null;
107
189
  }
108
- flushBuffer();
190
+ void enqueueFlush().catch(() => { });
109
191
  }
110
192
  else {
111
193
  scheduleFlush();
112
194
  }
113
195
  }
196
+ /** Record the latest firehose sequence seen. Source of truth for cursor persistence and reconnect resume. */
197
+ export function noteSeq(seq) {
198
+ lastSeq = seq;
199
+ }
200
+ /** The highest seq this process has seen, or null before the first event. */
201
+ export function getLastSeq() {
202
+ return lastSeq;
203
+ }
204
+ /**
205
+ * Persist the latest firehose sequence when it has advanced past the stored
206
+ * cursor. Runs on a timer independent of the write path: an app whose
207
+ * collections rarely appear on the firehose never flushes a batch, so the
208
+ * flush-path cursor write alone leaves the stored cursor frozen — and a frozen
209
+ * cursor makes the relay replay its whole retention window at line rate
210
+ * (~135 GB/hour observed against bsky.network) on every boot and reconnect.
211
+ */
212
+ export async function checkpointCursor() {
213
+ if (lastSeq === null || lastSeq === lastPersistedSeq)
214
+ return;
215
+ const seq = lastSeq;
216
+ try {
217
+ await setCursor(cursorKey, String(seq));
218
+ lastPersistedSeq = seq;
219
+ }
220
+ catch (err) {
221
+ emit('indexer', 'cursor_checkpoint_error', { cursor_seq: seq, error: err.message });
222
+ }
223
+ }
224
+ /** Point cursor persistence at a stream's own `_cursor` row. See {@link cursorKey}. */
225
+ export function setCursorKey(key) {
226
+ cursorKey = key;
227
+ }
228
+ /**
229
+ * The cursor a reconnect should resume from: the latest seq this process has
230
+ * seen, falling back to the boot-time cursor before any message has arrived.
231
+ * Reusing the boot-time cursor on every reconnect would replay everything
232
+ * received since the process started.
233
+ */
234
+ export function resumeCursor(liveSeq, bootCursor) {
235
+ return liveSeq !== null ? String(liveSeq) : bootCursor;
236
+ }
237
+ /** Reset module cursor state between tests. */
238
+ export function _resetCursorStateForTests() {
239
+ lastSeq = null;
240
+ lastPersistedSeq = null;
241
+ cursorKey = 'relay';
242
+ }
243
+ /**
244
+ * Forget which repos this process is tracking.
245
+ *
246
+ * The status cache is deliberately process-global — it is what keeps the
247
+ * commit hot path off the database — and it outlives any table a test
248
+ * truncates. A suite that clears `_repos` between cases and does not clear
249
+ * this sees the second case behave as though the first one's repos were
250
+ * still known, which is how the seam between tracking and backfill went
251
+ * untested.
252
+ */
253
+ export function _resetRepoTrackingForTests() {
254
+ repoStatusCache.clear();
255
+ backfillInFlight.clear();
256
+ backfillPromises.clear();
257
+ pendingReschedule.clear();
258
+ pendingBuffers.clear();
259
+ }
260
+ /**
261
+ * Drain the write buffer instead of waiting out FLUSH_INTERVAL_MS. Lets
262
+ * end-to-end tests assert on rows immediately after feeding a frame — deletes
263
+ * included, since they are buffered alongside puts and so are covered by the
264
+ * same await.
265
+ */
266
+ export async function _flushForTests() {
267
+ if (flushTimer) {
268
+ clearTimeout(flushTimer);
269
+ flushTimer = null;
270
+ }
271
+ await enqueueFlush();
272
+ }
273
+ /**
274
+ * Auto-backfill a DID's repo when first seen on the firehose.
275
+ *
276
+ * Fetches the full repo via CAR export, inserts all records, then replays any
277
+ * firehose events that arrived during the backfill. Concurrency is capped at
278
+ * `maxConcurrentBackfills`. Failed backfills retry with exponential delay up
279
+ * to `maxRetries`.
280
+ */
281
+ /** Wait for a DID's backfill to complete if one is in flight. */
282
+ export function awaitBackfill(did) {
283
+ const entry = backfillPromises.get(did);
284
+ return entry ? entry.promise : Promise.resolve();
285
+ }
286
+ /**
287
+ * Track a repo no signal collection will ever point at.
288
+ *
289
+ * Backfill and the stream find repos by what they write into the signal
290
+ * collections. A writer in a followed space is wanted for another reason:
291
+ * their public profile is what puts a name and a face on everything they
292
+ * wrote there, and an app that only indexes the community's own records
293
+ * would render every member as a DID. So the space engine names them here,
294
+ * and an unknown one is backfilled like a repo the stream just surfaced.
295
+ * Idempotent and cheap: a repo already known is a map lookup.
296
+ */
297
+ let indexerReferences = [];
298
+ /** Follow a dot path into a record; `$rkey` names the record key itself. */
299
+ function referencedDid(record, rkey, field) {
300
+ if (field === '$rkey')
301
+ return rkey.startsWith('did:') ? rkey : null;
302
+ let cur = record;
303
+ for (const part of field.split('.')) {
304
+ if (!cur || typeof cur !== 'object')
305
+ return null;
306
+ cur = cur[part];
307
+ }
308
+ return typeof cur === 'string' && cur.startsWith('did:') ? cur : null;
309
+ }
310
+ /**
311
+ * Track every repo a record names, per the configured references.
312
+ *
313
+ * Called wherever a record enters the index — the stream, a backfill, a
314
+ * space sync — so a roster read any of those ways brings its members' repos
315
+ * with it.
316
+ */
317
+ export function noteReferencedRepos(collection, rkey, record) {
318
+ for (const ref of indexerReferences) {
319
+ if (ref.collection !== collection)
320
+ continue;
321
+ const did = referencedDid(record, rkey, ref.field);
322
+ if (did)
323
+ trackRepo(did);
324
+ }
325
+ }
326
+ /**
327
+ * Apply the references to what is already indexed.
328
+ *
329
+ * A reference fires as a record comes in. Rows indexed before the reference
330
+ * was configured — or before this instance had it — would otherwise never
331
+ * name their repos until the record was written again. Run once at boot;
332
+ * a table of a few thousand rows is a moment, and the roster is what these
333
+ * point at, not the firehose.
334
+ */
335
+ export async function sweepReferences() {
336
+ let named = 0;
337
+ for (const ref of indexerReferences) {
338
+ let rows;
339
+ try {
340
+ rows = await unfilteredQuerySQL(`SELECT * FROM "${ref.collection}"`);
341
+ }
342
+ catch {
343
+ continue; // no table for this collection on this instance
344
+ }
345
+ for (const raw of rows) {
346
+ const row = reshapeRow(raw);
347
+ if (!row)
348
+ continue;
349
+ const did = referencedDid(row.value, rkeyOf(row.uri), ref.field);
350
+ if (!did)
351
+ continue;
352
+ named++;
353
+ trackRepo(did);
354
+ }
355
+ }
356
+ if (named > 0)
357
+ emit('indexer', 'references_swept', { named });
358
+ return named;
359
+ }
360
+ export function trackRepo(did) {
361
+ if (indexerPinnedRepos && !indexerPinnedRepos.has(did))
362
+ return;
363
+ const status = repoStatusCache.get(did);
364
+ if (status && status !== 'unknown')
365
+ return;
366
+ repoStatusCache.set(did, 'pending');
367
+ void triggerAutoBackfill(did);
368
+ }
114
369
  export async function triggerAutoBackfill(did, attempt = 0) {
115
370
  if (backfillInFlight.has(did))
116
371
  return;
@@ -126,6 +381,13 @@ export async function triggerAutoBackfill(did, attempt = 0) {
126
381
  }
127
382
  backfillInFlight.add(did);
128
383
  pendingBuffers.set(did, []);
384
+ if (!backfillPromises.has(did)) {
385
+ let resolveBackfill;
386
+ const promise = new Promise((r) => {
387
+ resolveBackfill = r;
388
+ });
389
+ backfillPromises.set(did, { promise, resolve: resolveBackfill });
390
+ }
129
391
  if (attempt === 0)
130
392
  await setRepoStatus(did, 'pending');
131
393
  const elapsed = timer();
@@ -146,12 +408,23 @@ export async function triggerAutoBackfill(did, attempt = 0) {
146
408
  backfillInFlight.delete(did);
147
409
  for (const item of buffered) {
148
410
  try {
149
- await insertRecord(item.collection, item.uri, item.cid, item.authorDid, item.record);
411
+ if (item.action === 'delete') {
412
+ // A delete that arrived mid-backfill has to be replayed after the CAR
413
+ // export lands, or the export's snapshot of the record outlives it.
414
+ await deleteRecord(item.collection, item.uri);
415
+ }
416
+ else {
417
+ await insertRecord(item.collection, item.uri, item.cid, item.authorDid, item.record);
418
+ }
150
419
  }
151
420
  catch {
152
421
  replayErrors++;
153
422
  }
154
423
  }
424
+ // The writes above, and the repo export before them, skipped the flush the
425
+ // classifiers listen on. An account's first posts land here, so queue them.
426
+ if (status === 'success')
427
+ noteBackfilled(did);
155
428
  // Schedule retry if failed and under maxRetries
156
429
  const retryInfo = status === 'error' ? await getRepoRetryInfo(did) : null;
157
430
  const currentRetryCount = retryInfo?.retryCount ?? 0;
@@ -165,6 +438,12 @@ export async function triggerAutoBackfill(did, attempt = 0) {
165
438
  error,
166
439
  retry_count: currentRetryCount,
167
440
  });
441
+ // Resolve awaiting callers (e.g. on-login hooks)
442
+ const entry = backfillPromises.get(did);
443
+ if (entry) {
444
+ entry.resolve();
445
+ backfillPromises.delete(did);
446
+ }
168
447
  if (status === 'error' && currentRetryCount < indexerMaxRetries) {
169
448
  const delaySecs = Math.min(currentRetryCount * 60, 3600);
170
449
  const delayMs = Math.max(delaySecs, 60) * 1000;
@@ -173,37 +452,26 @@ export async function triggerAutoBackfill(did, attempt = 0) {
173
452
  }, delayMs);
174
453
  }
175
454
  }
176
- // Periodic memory diagnostics
177
- function startMemoryDiagnostics() {
178
- setInterval(() => {
179
- const mem = process.memoryUsage();
180
- let pendingBufferItems = 0;
181
- for (const [, items] of pendingBuffers) {
182
- pendingBufferItems += items.length;
183
- }
184
- emit('diagnostics', 'memory', {
185
- heap_used_mb: Math.round(mem.heapUsed / 1024 / 1024),
186
- heap_total_mb: Math.round(mem.heapTotal / 1024 / 1024),
187
- rss_mb: Math.round(mem.rss / 1024 / 1024),
188
- external_mb: Math.round(mem.external / 1024 / 1024),
189
- array_buffers_mb: Math.round(mem.arrayBuffers / 1024 / 1024),
190
- write_buffer_len: buffer.length,
191
- pending_buffer_dids: pendingBuffers.size,
192
- pending_buffer_items: pendingBufferItems,
193
- backfill_in_flight: backfillInFlight.size,
194
- repo_status_cache_size: repoStatusCache.size,
195
- });
196
- }, 30_000);
197
- }
198
- export async function startIndexer(opts) {
199
- const { relayUrl, collections, cursor, fetchTimeout } = opts;
455
+ /**
456
+ * Connect to the AT Protocol relay firehose and begin indexing.
457
+ *
458
+ * Opens a WebSocket to `subscribeRepos`, processes commit messages synchronously
459
+ * on the event loop to minimize backpressure, and batches writes through
460
+ * {@link flushBuffer}. New DIDs trigger auto-backfill via {@link triggerAutoBackfill}.
461
+ * Reconnects automatically on disconnect after a 3s delay.
462
+ *
463
+ * @returns The WebSocket connection (for shutdown coordination)
464
+ */
465
+ export async function configureIndexer(opts) {
200
466
  if (opts.ftsRebuildInterval != null)
201
467
  ftsRebuildInterval = opts.ftsRebuildInterval;
202
- indexerCollections = collections;
203
- indexerSignalCollections = opts.signalCollections || collections;
468
+ indexerCollections = opts.collections;
469
+ indexerSignalCollections = opts.signalCollections || opts.collections;
470
+ indexerReferences = opts.references ?? [];
204
471
  indexerPinnedRepos = opts.pinnedRepos || null;
205
- indexerFetchTimeout = fetchTimeout;
472
+ indexerFetchTimeout = opts.fetchTimeout;
206
473
  indexerMaxRetries = opts.maxRetries;
474
+ indexerPlcUrl = opts.plcUrl;
207
475
  maxConcurrentBackfills = opts.parallelism ?? 3;
208
476
  // Pre-populate repo status cache from DB so non-signal updates
209
477
  // (e.g. profile changes) are processed for already-tracked DIDs
@@ -214,7 +482,18 @@ export async function startIndexer(opts) {
214
482
  }
215
483
  log(`[indexer] Warmed repo status cache with ${statuses.length} entries`);
216
484
  }
217
- startMemoryDiagnostics();
485
+ // Checkpoint the cursor on a timer regardless of write activity (see
486
+ // checkpointCursor). Guarded so reconnects don't stack intervals; unref'd so
487
+ // it never keeps the process alive.
488
+ if (!cursorCheckpointTimer) {
489
+ cursorCheckpointTimer = setInterval(() => void checkpointCursor(), CURSOR_CHECKPOINT_INTERVAL_MS);
490
+ cursorCheckpointTimer.unref?.();
491
+ }
492
+ }
493
+ export async function startIndexer(opts) {
494
+ const { relayUrl, collections, cursor } = opts;
495
+ setCursorKey('relay');
496
+ await configureIndexer(opts);
218
497
  let wsUrl = `${relayUrl}/xrpc/com.atproto.sync.subscribeRepos`;
219
498
  if (cursor) {
220
499
  wsUrl += `?cursor=${cursor}`;
@@ -232,41 +511,234 @@ export async function startIndexer(opts) {
232
511
  const bytes = new Uint8Array(event.data);
233
512
  processMessage(bytes, collections);
234
513
  }
235
- catch {
236
- // Skip unparseable firehose messages silently
514
+ catch (err) {
515
+ emit('indexer', 'decode_error', { error: err instanceof Error ? err.message : String(err) });
237
516
  }
238
517
  });
239
518
  ws.addEventListener('open', () => log('[indexer] Connected to relay'));
240
519
  ws.addEventListener('close', () => {
241
520
  log('[indexer] Disconnected, reconnecting in 3s...');
242
- setTimeout(() => startIndexer(opts), 3000);
521
+ setTimeout(() => startIndexer({ ...opts, cursor: resumeCursor(lastSeq, opts.cursor) }), 3000);
243
522
  });
244
523
  return ws;
245
524
  }
246
- function processMessage(bytes, collections) {
247
- const header = cborDecode(bytes, 0);
248
- const body = cborDecode(bytes, header.offset);
249
- if (header.value.op !== 1 || header.value.t !== '#commit')
525
+ /** The `_cursor` row an auxiliary firehose persists its position to. */
526
+ export function auxCursorKey(relayUrl) {
527
+ return `relay:${relayUrl}`;
528
+ }
529
+ /**
530
+ * The `_cursor` row a Jetstream instance persists its position to.
531
+ *
532
+ * Named for the instance, because a Jetstream sequence means nothing on
533
+ * another one. Pointed at a second instance — a region swap, an outage
534
+ * worked around — a shared row would hand over a number from somewhere else,
535
+ * and the stream would resume at whatever that happens to address there:
536
+ * no error, no refusal, just a silent jump past whatever fell in between.
537
+ */
538
+ export function jetstreamCursorKey(jetstreamUrl) {
539
+ return `jetstream:${jetstreamUrl}`;
540
+ }
541
+ /**
542
+ * Tail a second `subscribeRepos` alongside the primary stream.
543
+ *
544
+ * A relay is one coordinate system; a PDS tailed directly is another. Nothing
545
+ * below the wire cares which socket a frame arrived on — `processMessage`
546
+ * decodes it and `applyCommit` indexes it — so the only state an extra source
547
+ * needs of its own is a seq and a cursor row. Both live in this closure,
548
+ * keyed by URL, so an aux stream never advances (or resumes from) the primary
549
+ * cursor. Use this when a repo's PDS is not behind the relay being tailed —
550
+ * a self-hosted network, a dev stack with more than one PDS — rather than
551
+ * standing up a relay just to merge two streams.
552
+ *
553
+ * Must be called after {@link configureIndexer} (or {@link startIndexer}),
554
+ * which owns the shared indexer configuration.
555
+ */
556
+ export function startAuxIndexer(opts) {
557
+ const { relayUrl, collections } = opts;
558
+ const cursorKey = auxCursorKey(relayUrl);
559
+ let seq = null;
560
+ let persistedSeq = null;
561
+ const checkpoint = async () => {
562
+ if (seq === null || seq === persistedSeq)
563
+ return;
564
+ const s = seq;
565
+ try {
566
+ await setCursor(cursorKey, String(s));
567
+ persistedSeq = s;
568
+ }
569
+ catch (err) {
570
+ emit('indexer', 'cursor_checkpoint_error', { source: relayUrl, cursor_seq: s, error: err.message });
571
+ }
572
+ };
573
+ const timer = setInterval(() => void checkpoint(), CURSOR_CHECKPOINT_INTERVAL_MS);
574
+ timer.unref?.();
575
+ const connect = (cursor) => {
576
+ let wsUrl = `${relayUrl}/xrpc/com.atproto.sync.subscribeRepos`;
577
+ if (cursor) {
578
+ wsUrl += `?cursor=${cursor}`;
579
+ log(`[indexer:aux] Resuming ${relayUrl} from cursor ${cursor}`);
580
+ }
581
+ log(`[indexer:aux] Connecting to ${relayUrl}...`);
582
+ const ws = new WebSocket(wsUrl);
583
+ ws.binaryType = 'arraybuffer';
584
+ ws.addEventListener('message', (event) => {
585
+ try {
586
+ if (!(event.data instanceof ArrayBuffer))
587
+ return;
588
+ processMessage(new Uint8Array(event.data), collections, (s) => {
589
+ seq = s;
590
+ });
591
+ }
592
+ catch (err) {
593
+ emit('indexer', 'decode_error', { source: relayUrl, error: err instanceof Error ? err.message : String(err) });
594
+ }
595
+ });
596
+ ws.addEventListener('open', () => log(`[indexer:aux] Connected to ${relayUrl}`));
597
+ ws.addEventListener('close', () => {
598
+ log(`[indexer:aux] Disconnected from ${relayUrl}, reconnecting in 3s...`);
599
+ setTimeout(() => connect(resumeCursor(seq, opts.cursor)), 3000);
600
+ });
601
+ return ws;
602
+ };
603
+ return connect(opts.cursor);
604
+ }
605
+ /**
606
+ * Handle a `#identity` firehose event for a DID. The `handle` field on the
607
+ * event is optional per the lexicon, and some emitters omit it (signalling
608
+ * "re-resolve"). When absent, we re-resolve from the PLC directory so handle
609
+ * renames propagate even when the relay payload is sparse.
610
+ *
611
+ * Only updates DIDs we already track (present in repoStatusCache) to avoid
612
+ * writing rows for the entire network.
613
+ */
614
+ export async function handleIdentityEvent(did, payloadHandle) {
615
+ if (!repoStatusCache.has(did))
250
616
  return;
251
- if (!body.value.blocks || !body.value.ops)
617
+ let handle = payloadHandle;
618
+ const payloadHadHandle = handle !== undefined;
619
+ if (!handle) {
620
+ try {
621
+ // Bound the PLC fetch so a slow plc.directory can't pile up unbounded
622
+ // promises during an identity-event burst (fire-and-forget caller).
623
+ const res = await fetch(`${indexerPlcUrl}/${did}`, {
624
+ signal: AbortSignal.timeout(indexerFetchTimeout * 1000),
625
+ });
626
+ if (res.ok) {
627
+ const doc = (await res.json());
628
+ // First at:// entry is the canonical handle (per @atproto/identity convention)
629
+ const aka = doc.alsoKnownAs?.find((u) => u.startsWith('at://'));
630
+ handle = aka ? aka.slice('at://'.length) : undefined;
631
+ }
632
+ else {
633
+ emit('indexer', 'identity_resolve_error', { did, status: res.status });
634
+ }
635
+ }
636
+ catch (err) {
637
+ emit('indexer', 'identity_resolve_error', {
638
+ did,
639
+ error: err instanceof Error ? err.message : String(err),
640
+ });
641
+ }
642
+ }
643
+ if (!handle) {
644
+ emit('indexer', 'identity_no_handle', { did, payload_had_handle: payloadHadHandle });
252
645
  return;
253
- // Track sequence number for cursor
254
- if (body.value.seq)
255
- lastSeq = body.value.seq;
256
- const did = body.value.repo;
257
- if (!did)
646
+ }
647
+ try {
648
+ await updateRepoHandle(did, handle);
649
+ emit('indexer', 'identity_handle_update', { did, handle, payload_had_handle: payloadHadHandle });
650
+ }
651
+ catch (err) {
652
+ emit('indexer', 'identity_update_error', {
653
+ did,
654
+ handle,
655
+ error: err instanceof Error ? err.message : String(err),
656
+ });
657
+ }
658
+ }
659
+ /**
660
+ * Handle an `#account` event: the network saying an account is active again,
661
+ * or is not — deactivated, suspended, taken down by its host, or deleted.
662
+ *
663
+ * - deleted: every record it holds here is removed, and it is marked
664
+ * `deleted`.
665
+ * - deactivated, suspended, taken down by its host: marked `deactivated`. Its
666
+ * records stay, and no read serves them (see HIDDEN_REPO_STATUSES), so an
667
+ * account that comes back is whole again at once.
668
+ * - active again, after either: marked `pending` and backfilled, since it may
669
+ * have changed while it was gone.
670
+ *
671
+ * An admin's own takedown here wins over all of it: only reverse-takedown
672
+ * lifts that. Statuses that say nothing about the account itself (a relay's
673
+ * `desynchronized` or `throttled`) change nothing. Like identity events, only
674
+ * DIDs already tracked are touched.
675
+ */
676
+ export async function handleAccountEvent(did, active, status) {
677
+ if (!repoStatusCache.has(did))
678
+ return;
679
+ try {
680
+ const current = await getRepoStatus(did);
681
+ if (current === null || current === 'takendown')
682
+ return;
683
+ if (!active && status === 'deleted') {
684
+ const removed = await purgeRepoRecords(did);
685
+ await setRepoStatus(did, 'deleted');
686
+ repoStatusCache.set(did, 'deleted');
687
+ emit('indexer', 'account_deleted', { did, removed });
688
+ return;
689
+ }
690
+ if (!active && status && INACTIVE_ACCOUNT_STATUSES.has(status)) {
691
+ if (current === 'deactivated')
692
+ return;
693
+ await setRepoStatus(did, 'deactivated');
694
+ repoStatusCache.set(did, 'deactivated');
695
+ emit('indexer', 'account_deactivated', { did, status });
696
+ return;
697
+ }
698
+ if (active && (current === 'deactivated' || current === 'deleted')) {
699
+ await setRepoStatus(did, 'pending');
700
+ repoStatusCache.set(did, 'pending');
701
+ emit('indexer', 'account_reactivated', { did, was: current });
702
+ void triggerAutoBackfill(did);
703
+ }
704
+ }
705
+ catch (err) {
706
+ emit('indexer', 'account_event_error', {
707
+ did,
708
+ error: err instanceof Error ? err.message : String(err),
709
+ });
710
+ }
711
+ }
712
+ /** `#account` statuses that mean the account itself is not there. */
713
+ const INACTIVE_ACCOUNT_STATUSES = new Set(['deactivated', 'suspended', 'takendown']);
714
+ /**
715
+ * Whether a collection's records may be written from network data.
716
+ *
717
+ * Private collections are AppView-authoritative: nothing on the network may
718
+ * write or delete their rows, so stream ops naming them are spoofed by
719
+ * definition and must be dropped regardless of which source delivered them.
720
+ */
721
+ export function isIndexableCollection(collection, collections) {
722
+ return collections.has(collection) && !isPrivateCollection(collection);
723
+ }
724
+ /**
725
+ * Apply a repo's commit ops to the index.
726
+ *
727
+ * Wire-agnostic: the relay path reaches here after CBOR/CAR decoding, the
728
+ * Jetstream path after parsing JSON. Callers must have already filtered `ops`
729
+ * through {@link isIndexableCollection}.
730
+ *
731
+ * Handles auto-backfill signalling, buffering for DIDs mid-backfill, lexicon
732
+ * validation, and delete/put routing.
733
+ */
734
+ export function applyCommit(did, ops) {
735
+ if (ops.length === 0)
258
736
  return;
259
737
  // When repos are pinned, only process events from those DIDs
260
738
  if (indexerPinnedRepos && !indexerPinnedRepos.has(did))
261
739
  return;
262
- // Check if any ops in this commit are for collections we care about
263
- const relevantOps = body.value.ops.filter((op) => collections.has(op.path.split('/')[0]));
264
- if (relevantOps.length === 0)
265
- return;
266
- // Copy blocks out of the original buffer before it can be GC'd
267
- const { blocks } = parseCarFrame(new Uint8Array(body.value.blocks));
268
740
  // Only auto-backfill when we see activity in a signal collection
269
- const hasSignalOp = relevantOps.some((op) => indexerSignalCollections.has(op.path.split('/')[0]));
741
+ const hasSignalOp = ops.some((op) => indexerSignalCollections.has(op.collection));
270
742
  // Use in-memory cache only — never hit DB from the hot path.
271
743
  // Unknown DIDs stay unknown until backfill or auto-backfill discovers them.
272
744
  // The cache is populated by triggerAutoBackfill and setRepoStatus calls.
@@ -275,7 +747,7 @@ function processMessage(bytes, collections) {
275
747
  if (cachedStatus === undefined) {
276
748
  repoStatusCache.set(did, 'unknown');
277
749
  }
278
- if (hasSignalOp && (!indexerPinnedRepos || indexerPinnedRepos.has(did))) {
750
+ if (hasSignalOp) {
279
751
  if (repoStatus === null && backfillInFlight.size < maxConcurrentBackfills) {
280
752
  repoStatusCache.set(did, 'pending');
281
753
  triggerAutoBackfill(did);
@@ -286,43 +758,106 @@ function processMessage(bytes, collections) {
286
758
  }
287
759
  }
288
760
  // For non-signal ops (e.g. profile updates), only process if this DID is already tracked
289
- if (!hasSignalOp) {
290
- if (repoStatus === null)
291
- return;
761
+ if (!hasSignalOp && repoStatus === null)
762
+ return;
763
+ /** Queue a write behind anything already queued for this DID. */
764
+ const enqueue = (item) => {
765
+ // If DID is mid-backfill, buffer instead of writing directly
766
+ if (pendingBuffers.has(did))
767
+ pendingBuffers.get(did).push(item);
768
+ else
769
+ bufferWrite(item);
770
+ };
771
+ for (const op of ops) {
772
+ const uri = `at://${did}/${op.collection}/${op.rkey}`;
773
+ if (op.action === 'delete') {
774
+ // Buffered, not applied here: see {@link WriteBuffer}. The on-commit hook
775
+ // fires from the flush too, so a handler never sees a delete announced
776
+ // before the row is actually gone.
777
+ enqueue({ action: 'delete', collection: op.collection, uri, authorDid: did });
778
+ continue;
779
+ }
780
+ const record = op.record;
781
+ if (!op.cid || !record)
782
+ continue;
783
+ if (record.$type !== op.collection)
784
+ continue;
785
+ const validationError = validateRecord(getLexiconArray(), op.collection, record);
786
+ if (validationError) {
787
+ emit('indexer', 'validation_skip', {
788
+ uri,
789
+ collection: op.collection,
790
+ path: validationError.path,
791
+ error: validationError.message,
792
+ });
793
+ continue;
794
+ }
795
+ noteReferencedRepos(op.collection, op.rkey, record);
796
+ enqueue({ action: 'put', collection: op.collection, uri, cid: op.cid, authorDid: did, record });
292
797
  }
798
+ }
799
+ /**
800
+ * Process a single firehose message. Decodes the CBOR header/body, filters
801
+ * for relevant collections, validates records against lexicons, and routes
802
+ * writes to the buffer (or pending buffer if the DID is mid-backfill).
803
+ */
804
+ export function processMessage(bytes, collections, onSeq = noteSeq) {
805
+ const header = cborDecode(bytes, 0);
806
+ const body = cborDecode(bytes, header.offset);
807
+ // Handle identity events (handle changes). Fire-and-forget — keeps
808
+ // processMessage synchronous so the WS event loop drains without backpressure.
809
+ if (header.value.t === '#identity') {
810
+ const did = typeof body.value.did === 'string' ? body.value.did : undefined;
811
+ const handle = typeof body.value.handle === 'string' ? body.value.handle : undefined;
812
+ if (did)
813
+ handleIdentityEvent(did, handle);
814
+ return;
815
+ }
816
+ // Account status changes (deactivated, deleted, back again). Fire-and-forget,
817
+ // like identity.
818
+ if (header.value.t === '#account') {
819
+ const did = typeof body.value.did === 'string' ? body.value.did : undefined;
820
+ const status = typeof body.value.status === 'string' ? body.value.status : undefined;
821
+ if (did)
822
+ void handleAccountEvent(did, body.value.active === true, status);
823
+ return;
824
+ }
825
+ if (header.value.op !== 1 || header.value.t !== '#commit')
826
+ return;
827
+ if (!body.value.blocks || !body.value.ops)
828
+ return;
829
+ // Track sequence number for cursor
830
+ if (body.value.seq)
831
+ onSeq(body.value.seq);
832
+ const did = body.value.repo;
833
+ if (!did)
834
+ return;
835
+ // Check if any ops in this commit are for collections we care about, before
836
+ // paying for the CAR parse below.
837
+ const relevantOps = body.value.ops.filter((op) => isIndexableCollection(op.path.split('/')[0], collections));
838
+ if (relevantOps.length === 0)
839
+ return;
840
+ // Copy blocks out of the original buffer before it can be GC'd
841
+ const { blocks } = parseCarFrame(new Uint8Array(body.value.blocks));
842
+ const ops = [];
293
843
  for (const op of relevantOps) {
294
844
  const collection = op.path.split('/')[0];
295
- const uri = `at://${did}/${op.path}`;
845
+ const rkey = op.path.split('/').slice(1).join('/');
296
846
  if (op.action === 'delete') {
297
- deleteRecord(collection, uri);
847
+ ops.push({ action: 'delete', collection, rkey });
298
848
  continue;
299
849
  }
300
- for (const [cid, data] of blocks) {
301
- try {
302
- const { value: record } = cborDecode(data);
303
- if (record?.$type === collection) {
304
- const validationError = validateRecord(getLexiconArray(), collection, record);
305
- if (validationError) {
306
- emit('indexer', 'validation_skip', {
307
- uri,
308
- collection,
309
- path: validationError.path,
310
- error: validationError.message,
311
- });
312
- break;
313
- }
314
- const item = { collection, uri, cid, authorDid: did, record };
315
- // If DID is mid-backfill, buffer instead of writing directly
316
- if (pendingBuffers.has(did)) {
317
- pendingBuffers.get(did).push(item);
318
- }
319
- else {
320
- bufferWrite(item);
321
- }
322
- break;
323
- }
324
- }
325
- catch { }
850
+ const opCid = typeof op.cid === 'string' ? op.cid : op.cid?.$link;
851
+ if (!opCid)
852
+ continue;
853
+ const data = blocks.get(opCid);
854
+ if (!data)
855
+ continue;
856
+ try {
857
+ const { value: record } = cborDecode(data);
858
+ ops.push({ action: op.action === 'update' ? 'update' : 'create', collection, rkey, cid: opCid, record });
326
859
  }
860
+ catch { }
327
861
  }
862
+ applyCommit(did, ops);
328
863
  }