@hatk/hatk 0.0.1-alpha.7 → 0.0.1-alpha.70

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 (169) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +108 -0
  4. package/dist/backfill.d.ts +21 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +116 -43
  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 +32 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +41 -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 +43 -0
  60. package/dist/indexer.d.ts.map +1 -1
  61. package/dist/indexer.js +246 -38
  62. package/dist/labels.d.ts +36 -0
  63. package/dist/labels.d.ts.map +1 -1
  64. package/dist/labels.js +71 -6
  65. package/dist/lexicon-resolve.d.ts.map +1 -1
  66. package/dist/lexicon-resolve.js +27 -112
  67. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  68. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  69. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  70. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  71. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  72. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  73. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  74. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  75. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  76. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  77. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  78. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  79. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  80. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  81. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  82. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  83. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  84. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  85. package/dist/logger.d.ts +29 -0
  86. package/dist/logger.d.ts.map +1 -1
  87. package/dist/logger.js +29 -0
  88. package/dist/main.js +148 -68
  89. package/dist/mst.d.ts +18 -1
  90. package/dist/mst.d.ts.map +1 -1
  91. package/dist/mst.js +19 -8
  92. package/dist/oauth/db.d.ts +3 -1
  93. package/dist/oauth/db.d.ts.map +1 -1
  94. package/dist/oauth/db.js +56 -19
  95. package/dist/oauth/discovery.d.ts.map +1 -1
  96. package/dist/oauth/discovery.js +13 -1
  97. package/dist/oauth/server.d.ts +38 -0
  98. package/dist/oauth/server.d.ts.map +1 -1
  99. package/dist/oauth/server.js +228 -31
  100. package/dist/oauth/session.d.ts +11 -0
  101. package/dist/oauth/session.d.ts.map +1 -0
  102. package/dist/oauth/session.js +65 -0
  103. package/dist/opengraph.d.ts +10 -0
  104. package/dist/opengraph.d.ts.map +1 -1
  105. package/dist/opengraph.js +80 -40
  106. package/dist/pds-proxy.d.ts +60 -0
  107. package/dist/pds-proxy.d.ts.map +1 -0
  108. package/dist/pds-proxy.js +277 -0
  109. package/dist/private-collections.d.ts +8 -0
  110. package/dist/private-collections.d.ts.map +1 -0
  111. package/dist/private-collections.js +22 -0
  112. package/dist/push.d.ts +34 -0
  113. package/dist/push.d.ts.map +1 -0
  114. package/dist/push.js +186 -0
  115. package/dist/renderer.d.ts +27 -0
  116. package/dist/renderer.d.ts.map +1 -0
  117. package/dist/renderer.js +46 -0
  118. package/dist/resolve-hatk.d.ts +6 -0
  119. package/dist/resolve-hatk.d.ts.map +1 -0
  120. package/dist/resolve-hatk.js +20 -0
  121. package/dist/response.d.ts +16 -0
  122. package/dist/response.d.ts.map +1 -0
  123. package/dist/response.js +69 -0
  124. package/dist/scanner.d.ts +21 -0
  125. package/dist/scanner.d.ts.map +1 -0
  126. package/dist/scanner.js +88 -0
  127. package/dist/seed.d.ts +19 -0
  128. package/dist/seed.d.ts.map +1 -1
  129. package/dist/seed.js +43 -4
  130. package/dist/server-init.d.ts +8 -0
  131. package/dist/server-init.d.ts.map +1 -0
  132. package/dist/server-init.js +62 -0
  133. package/dist/server.d.ts +26 -3
  134. package/dist/server.d.ts.map +1 -1
  135. package/dist/server.js +729 -638
  136. package/dist/setup.d.ts +28 -1
  137. package/dist/setup.d.ts.map +1 -1
  138. package/dist/setup.js +50 -3
  139. package/dist/templates/feed.tpl +14 -0
  140. package/dist/templates/hook.tpl +5 -0
  141. package/dist/templates/label.tpl +15 -0
  142. package/dist/templates/og.tpl +17 -0
  143. package/dist/templates/seed.tpl +11 -0
  144. package/dist/templates/setup.tpl +5 -0
  145. package/dist/templates/test-feed.tpl +19 -0
  146. package/dist/templates/test-xrpc.tpl +19 -0
  147. package/dist/templates/xrpc.tpl +41 -0
  148. package/dist/test.d.ts +1 -1
  149. package/dist/test.d.ts.map +1 -1
  150. package/dist/test.js +41 -32
  151. package/dist/views.js +1 -1
  152. package/dist/vite-plugin.d.ts +1 -1
  153. package/dist/vite-plugin.d.ts.map +1 -1
  154. package/dist/vite-plugin.js +254 -66
  155. package/dist/xrpc.d.ts +75 -11
  156. package/dist/xrpc.d.ts.map +1 -1
  157. package/dist/xrpc.js +189 -39
  158. package/package.json +17 -9
  159. package/public/admin-auth.js +271 -306
  160. package/public/admin.html +177 -61
  161. package/dist/db.d.ts.map +0 -1
  162. package/dist/fts.d.ts.map +0 -1
  163. package/dist/oauth/hooks.d.ts +0 -10
  164. package/dist/oauth/hooks.d.ts.map +0 -1
  165. package/dist/oauth/hooks.js +0 -40
  166. package/dist/schema.d.ts.map +0 -1
  167. package/dist/test-browser.d.ts +0 -14
  168. package/dist/test-browser.d.ts.map +0 -1
  169. package/dist/test-browser.js +0 -26
package/dist/indexer.js CHANGED
@@ -1,24 +1,30 @@
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;
13
17
  const BATCH_SIZE = 100;
14
18
  const FLUSH_INTERVAL_MS = 500;
19
+ const CURSOR_CHECKPOINT_INTERVAL_MS = 5_000;
15
20
  let writesSinceRebuild = 0;
16
21
  let ftsRebuildInterval = 500;
17
22
  // Event buffer for DIDs mid-backfill
18
23
  const pendingBuffers = new Map();
19
24
  // Track in-flight backfills to avoid duplicates
20
25
  const backfillInFlight = new Set();
21
- const MAX_CONCURRENT_BACKFILLS = 5;
26
+ const backfillPromises = new Map();
27
+ const pendingReschedule = new Set();
22
28
  // In-memory cache of repo status to avoid flooding the DB read queue
23
29
  const repoStatusCache = new Map();
24
30
  // Set by startIndexer
@@ -27,6 +33,13 @@ let indexerSignalCollections;
27
33
  let indexerPinnedRepos = null;
28
34
  let indexerFetchTimeout;
29
35
  let indexerMaxRetries;
36
+ let indexerPlcUrl;
37
+ let maxConcurrentBackfills = 3;
38
+ /**
39
+ * Flush the write buffer — insert all buffered records, update the relay cursor,
40
+ * run label rules on inserted records, and trigger FTS rebuilds when the write
41
+ * threshold is reached. Emits a wide event with batch stats.
42
+ */
30
43
  async function flushBuffer() {
31
44
  if (buffer.length === 0)
32
45
  return;
@@ -47,8 +60,10 @@ async function flushBuffer() {
47
60
  }
48
61
  }
49
62
  if (lastSeq !== null) {
63
+ const seq = lastSeq;
50
64
  try {
51
- await setCursor('relay', String(lastSeq));
65
+ await setCursor('relay', String(seq));
66
+ lastPersistedSeq = seq;
52
67
  }
53
68
  catch (err) {
54
69
  cursorError = err.message;
@@ -64,6 +79,14 @@ async function flushBuffer() {
64
79
  value: item.record,
65
80
  }).catch(() => { });
66
81
  }
82
+ // Fire on-commit hooks for inserted records (async, non-blocking)
83
+ fireOnCommitHooks(inserted.map((item) => ({
84
+ action: 'create',
85
+ collection: item.collection,
86
+ uri: item.uri,
87
+ authorDid: item.authorDid,
88
+ record: item.record,
89
+ })));
67
90
  // Aggregate collection counts and unique DIDs for wide event
68
91
  const collections = {};
69
92
  const dids = new Set();
@@ -86,9 +109,14 @@ async function flushBuffer() {
86
109
  writesSinceRebuild += batch.length;
87
110
  if (writesSinceRebuild >= ftsRebuildInterval) {
88
111
  writesSinceRebuild = 0;
89
- rebuildAllIndexes([...indexerCollections]).catch(() => { });
112
+ // Skip periodic full rebuild for SQLite — it uses incremental FTS updates
113
+ const port = getDatabasePort();
114
+ if (port.dialect !== 'sqlite') {
115
+ rebuildAllIndexes([...indexerCollections]).catch(() => { });
116
+ }
90
117
  }
91
118
  }
119
+ /** Schedule a flush after FLUSH_INTERVAL_MS if one isn't already pending. */
92
120
  function scheduleFlush() {
93
121
  if (flushTimer)
94
122
  return;
@@ -97,6 +125,7 @@ function scheduleFlush() {
97
125
  await flushBuffer();
98
126
  }, FLUSH_INTERVAL_MS);
99
127
  }
128
+ /** Add a record to the write buffer. Flushes immediately if BATCH_SIZE is reached. */
100
129
  function bufferWrite(item) {
101
130
  buffer.push(item);
102
131
  if (buffer.length >= BATCH_SIZE) {
@@ -110,11 +139,79 @@ function bufferWrite(item) {
110
139
  scheduleFlush();
111
140
  }
112
141
  }
142
+ /** Record the latest firehose sequence seen. Source of truth for cursor persistence and reconnect resume. */
143
+ export function noteSeq(seq) {
144
+ lastSeq = seq;
145
+ }
146
+ /**
147
+ * Persist the latest firehose sequence when it has advanced past the stored
148
+ * cursor. Runs on a timer independent of the write path: an app whose
149
+ * collections rarely appear on the firehose never flushes a batch, so the
150
+ * flush-path cursor write alone leaves the stored cursor frozen — and a frozen
151
+ * cursor makes the relay replay its whole retention window at line rate
152
+ * (~135 GB/hour observed against bsky.network) on every boot and reconnect.
153
+ */
154
+ export async function checkpointCursor() {
155
+ if (lastSeq === null || lastSeq === lastPersistedSeq)
156
+ return;
157
+ const seq = lastSeq;
158
+ try {
159
+ await setCursor('relay', String(seq));
160
+ lastPersistedSeq = seq;
161
+ }
162
+ catch (err) {
163
+ emit('indexer', 'cursor_checkpoint_error', { cursor_seq: seq, error: err.message });
164
+ }
165
+ }
166
+ /**
167
+ * The cursor a reconnect should resume from: the latest seq this process has
168
+ * seen, falling back to the boot-time cursor before any message has arrived.
169
+ * Reusing the boot-time cursor on every reconnect would replay everything
170
+ * received since the process started.
171
+ */
172
+ export function resumeCursor(liveSeq, bootCursor) {
173
+ return liveSeq !== null ? String(liveSeq) : bootCursor;
174
+ }
175
+ /** Reset module cursor state between tests. */
176
+ export function _resetCursorStateForTests() {
177
+ lastSeq = null;
178
+ lastPersistedSeq = null;
179
+ }
180
+ /**
181
+ * Auto-backfill a DID's repo when first seen on the firehose.
182
+ *
183
+ * Fetches the full repo via CAR export, inserts all records, then replays any
184
+ * firehose events that arrived during the backfill. Concurrency is capped at
185
+ * `maxConcurrentBackfills`. Failed backfills retry with exponential delay up
186
+ * to `maxRetries`.
187
+ */
188
+ /** Wait for a DID's backfill to complete if one is in flight. */
189
+ export function awaitBackfill(did) {
190
+ const entry = backfillPromises.get(did);
191
+ return entry ? entry.promise : Promise.resolve();
192
+ }
113
193
  export async function triggerAutoBackfill(did, attempt = 0) {
114
194
  if (backfillInFlight.has(did))
115
195
  return;
196
+ if (backfillInFlight.size >= maxConcurrentBackfills) {
197
+ if (!pendingReschedule.has(did)) {
198
+ pendingReschedule.add(did);
199
+ setTimeout(() => {
200
+ pendingReschedule.delete(did);
201
+ triggerAutoBackfill(did, attempt);
202
+ }, 10_000);
203
+ }
204
+ return;
205
+ }
116
206
  backfillInFlight.add(did);
117
207
  pendingBuffers.set(did, []);
208
+ if (!backfillPromises.has(did)) {
209
+ let resolveBackfill;
210
+ const promise = new Promise((r) => {
211
+ resolveBackfill = r;
212
+ });
213
+ backfillPromises.set(did, { promise, resolve: resolveBackfill });
214
+ }
118
215
  if (attempt === 0)
119
216
  await setRepoStatus(did, 'pending');
120
217
  const elapsed = timer();
@@ -154,6 +251,12 @@ export async function triggerAutoBackfill(did, attempt = 0) {
154
251
  error,
155
252
  retry_count: currentRetryCount,
156
253
  });
254
+ // Resolve awaiting callers (e.g. on-login hooks)
255
+ const entry = backfillPromises.get(did);
256
+ if (entry) {
257
+ entry.resolve();
258
+ backfillPromises.delete(did);
259
+ }
157
260
  if (status === 'error' && currentRetryCount < indexerMaxRetries) {
158
261
  const delaySecs = Math.min(currentRetryCount * 60, 3600);
159
262
  const delayMs = Math.max(delaySecs, 60) * 1000;
@@ -162,7 +265,7 @@ export async function triggerAutoBackfill(did, attempt = 0) {
162
265
  }, delayMs);
163
266
  }
164
267
  }
165
- // Periodic memory diagnostics
268
+ /** Emit a memory diagnostics wide event every 30s for observability. */
166
269
  function startMemoryDiagnostics() {
167
270
  setInterval(() => {
168
271
  const mem = process.memoryUsage();
@@ -184,6 +287,16 @@ function startMemoryDiagnostics() {
184
287
  });
185
288
  }, 30_000);
186
289
  }
290
+ /**
291
+ * Connect to the AT Protocol relay firehose and begin indexing.
292
+ *
293
+ * Opens a WebSocket to `subscribeRepos`, processes commit messages synchronously
294
+ * on the event loop to minimize backpressure, and batches writes through
295
+ * {@link flushBuffer}. New DIDs trigger auto-backfill via {@link triggerAutoBackfill}.
296
+ * Reconnects automatically on disconnect after a 3s delay.
297
+ *
298
+ * @returns The WebSocket connection (for shutdown coordination)
299
+ */
187
300
  export async function startIndexer(opts) {
188
301
  const { relayUrl, collections, cursor, fetchTimeout } = opts;
189
302
  if (opts.ftsRebuildInterval != null)
@@ -193,6 +306,8 @@ export async function startIndexer(opts) {
193
306
  indexerPinnedRepos = opts.pinnedRepos || null;
194
307
  indexerFetchTimeout = fetchTimeout;
195
308
  indexerMaxRetries = opts.maxRetries;
309
+ indexerPlcUrl = opts.plcUrl;
310
+ maxConcurrentBackfills = opts.parallelism ?? 3;
196
311
  // Pre-populate repo status cache from DB so non-signal updates
197
312
  // (e.g. profile changes) are processed for already-tracked DIDs
198
313
  if (repoStatusCache.size === 0) {
@@ -202,7 +317,14 @@ export async function startIndexer(opts) {
202
317
  }
203
318
  log(`[indexer] Warmed repo status cache with ${statuses.length} entries`);
204
319
  }
205
- startMemoryDiagnostics();
320
+ // startMemoryDiagnostics()
321
+ // Checkpoint the cursor on a timer regardless of write activity (see
322
+ // checkpointCursor). Guarded so reconnects don't stack intervals; unref'd so
323
+ // it never keeps the process alive.
324
+ if (!cursorCheckpointTimer) {
325
+ cursorCheckpointTimer = setInterval(() => void checkpointCursor(), CURSOR_CHECKPOINT_INTERVAL_MS);
326
+ cursorCheckpointTimer.unref?.();
327
+ }
206
328
  let wsUrl = `${relayUrl}/xrpc/com.atproto.sync.subscribeRepos`;
207
329
  if (cursor) {
208
330
  wsUrl += `?cursor=${cursor}`;
@@ -220,27 +342,95 @@ export async function startIndexer(opts) {
220
342
  const bytes = new Uint8Array(event.data);
221
343
  processMessage(bytes, collections);
222
344
  }
223
- catch {
224
- // Skip unparseable firehose messages silently
345
+ catch (err) {
346
+ emit('indexer', 'decode_error', { error: err instanceof Error ? err.message : String(err) });
225
347
  }
226
348
  });
227
349
  ws.addEventListener('open', () => log('[indexer] Connected to relay'));
228
350
  ws.addEventListener('close', () => {
229
351
  log('[indexer] Disconnected, reconnecting in 3s...');
230
- setTimeout(() => startIndexer(opts), 3000);
352
+ setTimeout(() => startIndexer({ ...opts, cursor: resumeCursor(lastSeq, opts.cursor) }), 3000);
231
353
  });
232
354
  return ws;
233
355
  }
356
+ /**
357
+ * Handle a `#identity` firehose event for a DID. The `handle` field on the
358
+ * event is optional per the lexicon, and some emitters omit it (signalling
359
+ * "re-resolve"). When absent, we re-resolve from the PLC directory so handle
360
+ * renames propagate even when the relay payload is sparse.
361
+ *
362
+ * Only updates DIDs we already track (present in repoStatusCache) to avoid
363
+ * writing rows for the entire network.
364
+ */
365
+ async function handleIdentityEvent(did, payloadHandle) {
366
+ if (!repoStatusCache.has(did))
367
+ return;
368
+ let handle = payloadHandle;
369
+ const payloadHadHandle = handle !== undefined;
370
+ if (!handle) {
371
+ try {
372
+ // Bound the PLC fetch so a slow plc.directory can't pile up unbounded
373
+ // promises during an identity-event burst (fire-and-forget caller).
374
+ const res = await fetch(`${indexerPlcUrl}/${did}`, {
375
+ signal: AbortSignal.timeout(indexerFetchTimeout * 1000),
376
+ });
377
+ if (res.ok) {
378
+ const doc = (await res.json());
379
+ // First at:// entry is the canonical handle (per @atproto/identity convention)
380
+ const aka = doc.alsoKnownAs?.find((u) => u.startsWith('at://'));
381
+ handle = aka ? aka.slice('at://'.length) : undefined;
382
+ }
383
+ else {
384
+ emit('indexer', 'identity_resolve_error', { did, status: res.status });
385
+ }
386
+ }
387
+ catch (err) {
388
+ emit('indexer', 'identity_resolve_error', {
389
+ did,
390
+ error: err instanceof Error ? err.message : String(err),
391
+ });
392
+ }
393
+ }
394
+ if (!handle) {
395
+ emit('indexer', 'identity_no_handle', { did, payload_had_handle: payloadHadHandle });
396
+ return;
397
+ }
398
+ try {
399
+ await updateRepoHandle(did, handle);
400
+ emit('indexer', 'identity_handle_update', { did, handle, payload_had_handle: payloadHadHandle });
401
+ }
402
+ catch (err) {
403
+ emit('indexer', 'identity_update_error', {
404
+ did,
405
+ handle,
406
+ error: err instanceof Error ? err.message : String(err),
407
+ });
408
+ }
409
+ }
410
+ /**
411
+ * Process a single firehose message. Decodes the CBOR header/body, filters
412
+ * for relevant collections, validates records against lexicons, and routes
413
+ * writes to the buffer (or pending buffer if the DID is mid-backfill).
414
+ */
234
415
  function processMessage(bytes, collections) {
235
416
  const header = cborDecode(bytes, 0);
236
417
  const body = cborDecode(bytes, header.offset);
418
+ // Handle identity events (handle changes). Fire-and-forget — keeps
419
+ // processMessage synchronous so the WS event loop drains without backpressure.
420
+ if (header.value.t === '#identity') {
421
+ const did = typeof body.value.did === 'string' ? body.value.did : undefined;
422
+ const handle = typeof body.value.handle === 'string' ? body.value.handle : undefined;
423
+ if (did)
424
+ handleIdentityEvent(did, handle);
425
+ return;
426
+ }
237
427
  if (header.value.op !== 1 || header.value.t !== '#commit')
238
428
  return;
239
429
  if (!body.value.blocks || !body.value.ops)
240
430
  return;
241
431
  // Track sequence number for cursor
242
432
  if (body.value.seq)
243
- lastSeq = body.value.seq;
433
+ noteSeq(body.value.seq);
244
434
  const did = body.value.repo;
245
435
  if (!did)
246
436
  return;
@@ -248,7 +438,13 @@ function processMessage(bytes, collections) {
248
438
  if (indexerPinnedRepos && !indexerPinnedRepos.has(did))
249
439
  return;
250
440
  // 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]));
441
+ // Private collections are AppView-authoritative: nothing on the network may
442
+ // write or delete their rows, so firehose ops naming them are spoofed by
443
+ // definition and must be dropped.
444
+ const relevantOps = body.value.ops.filter((op) => {
445
+ const collection = op.path.split('/')[0];
446
+ return collections.has(collection) && !isPrivateCollection(collection);
447
+ });
252
448
  if (relevantOps.length === 0)
253
449
  return;
254
450
  // Copy blocks out of the original buffer before it can be GC'd
@@ -264,7 +460,7 @@ function processMessage(bytes, collections) {
264
460
  repoStatusCache.set(did, 'unknown');
265
461
  }
266
462
  if (hasSignalOp && (!indexerPinnedRepos || indexerPinnedRepos.has(did))) {
267
- if (repoStatus === null && backfillInFlight.size < MAX_CONCURRENT_BACKFILLS) {
463
+ if (repoStatus === null && backfillInFlight.size < maxConcurrentBackfills) {
268
464
  repoStatusCache.set(did, 'pending');
269
465
  triggerAutoBackfill(did);
270
466
  }
@@ -283,34 +479,46 @@ function processMessage(bytes, collections) {
283
479
  const uri = `at://${did}/${op.path}`;
284
480
  if (op.action === 'delete') {
285
481
  deleteRecord(collection, uri);
482
+ fireOnCommitHooks([
483
+ {
484
+ action: 'delete',
485
+ collection,
486
+ uri,
487
+ authorDid: did,
488
+ record: null,
489
+ },
490
+ ]);
286
491
  continue;
287
492
  }
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;
493
+ const opCid = typeof op.cid === 'string' ? op.cid : op.cid?.$link;
494
+ if (!opCid)
495
+ continue;
496
+ const data = blocks.get(opCid);
497
+ if (!data)
498
+ continue;
499
+ try {
500
+ const { value: record } = cborDecode(data);
501
+ if (record?.$type === collection) {
502
+ const validationError = validateRecord(getLexiconArray(), collection, record);
503
+ if (validationError) {
504
+ emit('indexer', 'validation_skip', {
505
+ uri,
506
+ collection,
507
+ path: validationError.path,
508
+ error: validationError.message,
509
+ });
510
+ continue;
511
+ }
512
+ const item = { collection, uri, cid: opCid, authorDid: did, record };
513
+ // If DID is mid-backfill, buffer instead of writing directly
514
+ if (pendingBuffers.has(did)) {
515
+ pendingBuffers.get(did).push(item);
516
+ }
517
+ else {
518
+ bufferWrite(item);
311
519
  }
312
520
  }
313
- catch { }
314
521
  }
522
+ catch { }
315
523
  }
316
524
  }
package/dist/labels.d.ts CHANGED
@@ -13,7 +13,36 @@ export interface LabelRuleContext {
13
13
  value: Record<string, any>;
14
14
  };
15
15
  }
16
+ export interface LabelModule {
17
+ definition?: LabelDefinition;
18
+ evaluate?: (ctx: LabelRuleContext) => Promise<string[]>;
19
+ }
20
+ export declare function defineLabel(module: LabelModule): {
21
+ definition?: LabelDefinition;
22
+ evaluate?: (ctx: LabelRuleContext) => Promise<string[]>;
23
+ __type: "labels";
24
+ };
25
+ /**
26
+ * Discover and load label rule modules from the `labels/` directory.
27
+ *
28
+ * Each module should default-export an object with an optional `definition`
29
+ * (label metadata like severity and blur behavior) and an optional `evaluate`
30
+ * function that returns label values to apply to a record.
31
+ *
32
+ * @param labelsDir - Absolute path to the `labels/` directory
33
+ */
16
34
  export declare function initLabels(labelsDir: string): Promise<void>;
35
+ /** Clear all registered label definitions and rules (for hot-reload). */
36
+ export declare function clearLabels(): void;
37
+ /** Register a single label module from a scanned server/ module. */
38
+ export declare function registerLabelModule(name: string, labelMod: {
39
+ definition?: LabelDefinition;
40
+ evaluate?: (ctx: LabelRuleContext) => Promise<string[]>;
41
+ }): void;
42
+ /**
43
+ * Evaluate all loaded label rules against a record and persist any resulting labels.
44
+ * Called after each record is indexed. Rule errors are logged but never block indexing.
45
+ */
17
46
  export declare function runLabelRules(record: {
18
47
  uri: string;
19
48
  cid: string;
@@ -21,9 +50,16 @@ export declare function runLabelRules(record: {
21
50
  collection: string;
22
51
  value: Record<string, any>;
23
52
  }): Promise<void>;
53
+ /**
54
+ * Re-evaluate all label rules against every existing record in the given collections.
55
+ * Used by `/admin/rescan-labels` to apply new or updated rules retroactively.
56
+ *
57
+ * @returns Count of records scanned and new labels applied
58
+ */
24
59
  export declare function rescanLabels(collections: string[]): Promise<{
25
60
  scanned: number;
26
61
  labeled: number;
27
62
  }>;
63
+ /** Return all label definitions discovered during {@link initLabels}. */
28
64
  export declare function getLabelDefinitions(): LabelDefinition[];
29
65
  //# sourceMappingURL=labels.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"labels.d.ts","sourceRoot":"","sources":["../src/labels.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAIlD,wDAAwD;AACxD,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE;QACF,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;QACtD,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;KACtD,CAAA;IACD,MAAM,EAAE;QACN,GAAG,EAAE,MAAM,CAAA;QACX,GAAG,EAAE,MAAM,CAAA;QACX,GAAG,EAAE,MAAM,CAAA;QACX,UAAU,EAAE,MAAM,CAAA;QAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;KAC3B,CAAA;CACF;AAWD,wBAAsB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAmCjE;AAED,wBAAsB,aAAa,CAAC,MAAM,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAC3B,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBhB;AAED,wBAAsB,YAAY,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAuCvG;AAED,wBAAgB,mBAAmB,IAAI,eAAe,EAAE,CAEvD"}
1
+ {"version":3,"file":"labels.d.ts","sourceRoot":"","sources":["../src/labels.ts"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAIlD,wDAAwD;AACxD,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE;QACF,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;QACtD,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;KACtD,CAAA;IACD,MAAM,EAAE;QACN,GAAG,EAAE,MAAM,CAAA;QACX,GAAG,EAAE,MAAM,CAAA;QACX,GAAG,EAAE,MAAM,CAAA;QACX,UAAU,EAAE,MAAM,CAAA;QAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;KAC3B,CAAA;CACF;AAED,MAAM,WAAW,WAAW;IAC1B,UAAU,CAAC,EAAE,eAAe,CAAA;IAC5B,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;CACxD;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW;iBAJhC,eAAe;eACjB,CAAC,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC;;EAKxD;AAYD;;;;;;;;GAQG;AACH,wBAAsB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAmCjE;AAED,yEAAyE;AACzE,wBAAgB,WAAW,IAAI,IAAI,CAGlC;AAED,oEAAoE;AACpE,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE;IAAE,UAAU,CAAC,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;CAAE,GAClG,IAAI,CAON;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAC3B,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBhB;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAuCvG;AAED,yEAAyE;AACzE,wBAAgB,mBAAmB,IAAI,eAAe,EAAE,CAEvD"}
package/dist/labels.js CHANGED
@@ -6,13 +6,53 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ /**
10
+ * Label system for applying moderation labels to records as they are indexed.
11
+ *
12
+ * Place label modules in the `labels/` directory. Each module default-exports
13
+ * an object with a `definition` (label metadata) and/or an `evaluate` function
14
+ * (rule that returns label values for a given record).
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * // labels/nsfw.ts
19
+ * import type { LabelRuleContext } from '@hatk/hatk/labels'
20
+ *
21
+ * export default {
22
+ * definition: {
23
+ * identifier: 'nsfw',
24
+ * severity: 'alert',
25
+ * blurs: 'media',
26
+ * defaultSetting: 'warn',
27
+ * locales: [{ lang: 'en', name: 'NSFW', description: 'Not safe for work' }],
28
+ * },
29
+ *
30
+ * async evaluate(ctx: LabelRuleContext): Promise<string[]> {
31
+ * if (ctx.record.value.nsfw === true) return ['nsfw']
32
+ * return []
33
+ * },
34
+ * }
35
+ * ```
36
+ */
9
37
  import { resolve } from 'node:path';
10
38
  import { readdirSync } from 'node:fs';
11
- import { querySQL, runSQL, insertLabels, getSchema } from "./db.js";
39
+ import { querySQL, runSQL, insertLabels, getSchema } from "./database/db.js";
12
40
  import { log, emit } from "./logger.js";
41
+ export function defineLabel(module) {
42
+ return { __type: 'labels', ...module };
43
+ }
13
44
  const rules = [];
14
45
  let labelDefs = [];
15
46
  let labelSrc = 'self';
47
+ /**
48
+ * Discover and load label rule modules from the `labels/` directory.
49
+ *
50
+ * Each module should default-export an object with an optional `definition`
51
+ * (label metadata like severity and blur behavior) and an optional `evaluate`
52
+ * function that returns label values to apply to a record.
53
+ *
54
+ * @param labelsDir - Absolute path to the `labels/` directory
55
+ */
16
56
  export async function initLabels(labelsDir) {
17
57
  let files;
18
58
  try {
@@ -26,7 +66,7 @@ export async function initLabels(labelsDir) {
26
66
  for (const file of files) {
27
67
  const name = file.replace(/\.(ts|js)$/, '');
28
68
  const scriptPath = resolve(labelsDir, file);
29
- const mod = await import(__rewriteRelativeImportExtension(scriptPath));
69
+ const mod = await import(__rewriteRelativeImportExtension(/* @vite-ignore */ `${scriptPath}?t=${Date.now()}`));
30
70
  const handler = mod.default;
31
71
  if (handler.definition) {
32
72
  labelDefs.push(handler.definition);
@@ -45,6 +85,24 @@ export async function initLabels(labelsDir) {
45
85
  log(`[labels] ${labelDefs.length} label definitions loaded`);
46
86
  }
47
87
  }
88
+ /** Clear all registered label definitions and rules (for hot-reload). */
89
+ export function clearLabels() {
90
+ labelDefs.length = 0;
91
+ rules.length = 0;
92
+ }
93
+ /** Register a single label module from a scanned server/ module. */
94
+ export function registerLabelModule(name, labelMod) {
95
+ if (labelMod.definition) {
96
+ labelDefs.push(labelMod.definition);
97
+ }
98
+ if (labelMod.evaluate) {
99
+ rules.push({ name, evaluate: labelMod.evaluate });
100
+ }
101
+ }
102
+ /**
103
+ * Evaluate all loaded label rules against a record and persist any resulting labels.
104
+ * Called after each record is indexed. Rule errors are logged but never block indexing.
105
+ */
48
106
  export async function runLabelRules(record) {
49
107
  if (rules.length === 0)
50
108
  return;
@@ -69,15 +127,21 @@ export async function runLabelRules(record) {
69
127
  emit('labels', 'applied', { count: allLabels.length, uri: record.uri, vals: allLabels.map((l) => l.val) });
70
128
  }
71
129
  }
130
+ /**
131
+ * Re-evaluate all label rules against every existing record in the given collections.
132
+ * Used by `/admin/rescan-labels` to apply new or updated rules retroactively.
133
+ *
134
+ * @returns Count of records scanned and new labels applied
135
+ */
72
136
  export async function rescanLabels(collections) {
73
- const beforeRows = await querySQL(`SELECT COUNT(*) as count FROM _labels`);
137
+ const beforeRows = (await querySQL(`SELECT COUNT(*) as count FROM _labels`));
74
138
  const beforeCount = Number(beforeRows[0]?.count || 0);
75
139
  let scanned = 0;
76
140
  for (const collection of collections) {
77
141
  const schema = getSchema(collection);
78
142
  if (!schema)
79
143
  continue;
80
- const rows = await querySQL(`SELECT * FROM ${schema.tableName}`);
144
+ const rows = (await querySQL(`SELECT * FROM ${schema.tableName}`));
81
145
  for (const row of rows) {
82
146
  scanned++;
83
147
  const value = {};
@@ -85,7 +149,7 @@ export async function rescanLabels(collections) {
85
149
  let v = row[col.name];
86
150
  if (v === null || v === undefined)
87
151
  continue;
88
- if (col.duckdbType === 'JSON' && typeof v === 'string') {
152
+ if (col.isJson && typeof v === 'string') {
89
153
  try {
90
154
  v = JSON.parse(v);
91
155
  }
@@ -102,10 +166,11 @@ export async function rescanLabels(collections) {
102
166
  });
103
167
  }
104
168
  }
105
- const afterRows = await querySQL(`SELECT COUNT(*) as count FROM _labels`);
169
+ const afterRows = (await querySQL(`SELECT COUNT(*) as count FROM _labels`));
106
170
  const afterCount = Number(afterRows[0]?.count || 0);
107
171
  return { scanned, labeled: afterCount - beforeCount };
108
172
  }
173
+ /** Return all label definitions discovered during {@link initLabels}. */
109
174
  export function getLabelDefinitions() {
110
175
  return labelDefs;
111
176
  }
@@ -1 +1 @@
1
- {"version":3,"file":"lexicon-resolve.d.ts","sourceRoot":"","sources":["../src/lexicon-resolve.ts"],"names":[],"mappings":"AAsMA,UAAU,OAAO;IACf,OAAO,EAAE,MAAM,CAAA;IACf,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACzB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB;AA+DD;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAgChF"}
1
+ {"version":3,"file":"lexicon-resolve.d.ts","sourceRoot":"","sources":["../src/lexicon-resolve.ts"],"names":[],"mappings":"AAoFA,UAAU,OAAO;IACf,OAAO,EAAE,MAAM,CAAA;IACf,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACzB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB;AA2FD;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAgChF"}