@hatk/hatk 0.0.1-alpha.7 → 0.0.1-alpha.71
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.
- package/dist/adapter.d.ts +19 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +108 -0
- package/dist/backfill.d.ts +21 -2
- package/dist/backfill.d.ts.map +1 -1
- package/dist/backfill.js +116 -43
- package/dist/car.d.ts +42 -10
- package/dist/car.d.ts.map +1 -1
- package/dist/car.js +154 -14
- package/dist/cli.js +243 -1043
- package/dist/config.d.ts +46 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +42 -9
- package/dist/database/adapter-factory.d.ts +6 -0
- package/dist/database/adapter-factory.d.ts.map +1 -0
- package/dist/database/adapter-factory.js +20 -0
- package/dist/database/adapters/duckdb-search.d.ts +12 -0
- package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
- package/dist/database/adapters/duckdb-search.js +27 -0
- package/dist/database/adapters/duckdb.d.ts +25 -0
- package/dist/database/adapters/duckdb.d.ts.map +1 -0
- package/dist/database/adapters/duckdb.js +161 -0
- package/dist/database/adapters/sqlite-search.d.ts +23 -0
- package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
- package/dist/database/adapters/sqlite-search.js +74 -0
- package/dist/database/adapters/sqlite.d.ts +18 -0
- package/dist/database/adapters/sqlite.d.ts.map +1 -0
- package/dist/database/adapters/sqlite.js +88 -0
- package/dist/{db.d.ts → database/db.d.ts} +57 -6
- package/dist/database/db.d.ts.map +1 -0
- package/dist/{db.js → database/db.js} +755 -552
- package/dist/database/dialect.d.ts +45 -0
- package/dist/database/dialect.d.ts.map +1 -0
- package/dist/database/dialect.js +72 -0
- package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
- package/dist/database/fts.d.ts.map +1 -0
- package/dist/{fts.js → database/fts.js} +116 -32
- package/dist/database/index.d.ts +7 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +6 -0
- package/dist/database/ports.d.ts +50 -0
- package/dist/database/ports.d.ts.map +1 -0
- package/dist/database/ports.js +1 -0
- package/dist/{schema.d.ts → database/schema.d.ts} +14 -3
- package/dist/database/schema.d.ts.map +1 -0
- package/dist/{schema.js → database/schema.js} +81 -41
- package/dist/dev-entry.d.ts +8 -0
- package/dist/dev-entry.d.ts.map +1 -0
- package/dist/dev-entry.js +119 -0
- package/dist/feeds.d.ts +12 -8
- package/dist/feeds.d.ts.map +1 -1
- package/dist/feeds.js +51 -6
- package/dist/hooks.d.ts +85 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +163 -0
- package/dist/hydrate.d.ts +7 -6
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +4 -16
- package/dist/indexer.d.ts +109 -3
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +338 -66
- package/dist/jetstream.d.ts +82 -0
- package/dist/jetstream.d.ts.map +1 -0
- package/dist/jetstream.js +161 -0
- package/dist/labels.d.ts +36 -0
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +71 -6
- package/dist/lexicon-resolve.d.ts.map +1 -1
- package/dist/lexicon-resolve.js +27 -112
- package/dist/lexicons/com/atproto/label/defs.json +75 -0
- package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
- package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
- package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
- package/dist/lexicons/dev/hatk/createRecord.json +40 -0
- package/dist/lexicons/dev/hatk/createReport.json +48 -0
- package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
- package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
- package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
- package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
- package/dist/lexicons/dev/hatk/getFeed.json +30 -0
- package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
- package/dist/lexicons/dev/hatk/getRecord.json +26 -0
- package/dist/lexicons/dev/hatk/getRecords.json +32 -0
- package/dist/lexicons/dev/hatk/putPreference.json +28 -0
- package/dist/lexicons/dev/hatk/putRecord.json +41 -0
- package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
- package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
- package/dist/logger.d.ts +29 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +29 -0
- package/dist/main.js +161 -72
- package/dist/mst.d.ts +18 -1
- package/dist/mst.d.ts.map +1 -1
- package/dist/mst.js +19 -8
- package/dist/oauth/db.d.ts +3 -1
- package/dist/oauth/db.d.ts.map +1 -1
- package/dist/oauth/db.js +56 -19
- package/dist/oauth/discovery.d.ts.map +1 -1
- package/dist/oauth/discovery.js +13 -1
- package/dist/oauth/server.d.ts +38 -0
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +228 -31
- package/dist/oauth/session.d.ts +11 -0
- package/dist/oauth/session.d.ts.map +1 -0
- package/dist/oauth/session.js +65 -0
- package/dist/opengraph.d.ts +10 -0
- package/dist/opengraph.d.ts.map +1 -1
- package/dist/opengraph.js +80 -40
- package/dist/pds-proxy.d.ts +60 -0
- package/dist/pds-proxy.d.ts.map +1 -0
- package/dist/pds-proxy.js +277 -0
- package/dist/private-collections.d.ts +8 -0
- package/dist/private-collections.d.ts.map +1 -0
- package/dist/private-collections.js +22 -0
- package/dist/push.d.ts +34 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +186 -0
- package/dist/renderer.d.ts +27 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +46 -0
- package/dist/resolve-hatk.d.ts +6 -0
- package/dist/resolve-hatk.d.ts.map +1 -0
- package/dist/resolve-hatk.js +20 -0
- package/dist/response.d.ts +16 -0
- package/dist/response.d.ts.map +1 -0
- package/dist/response.js +69 -0
- package/dist/scanner.d.ts +21 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +88 -0
- package/dist/seed.d.ts +19 -0
- package/dist/seed.d.ts.map +1 -1
- package/dist/seed.js +43 -4
- package/dist/server-init.d.ts +8 -0
- package/dist/server-init.d.ts.map +1 -0
- package/dist/server-init.js +62 -0
- package/dist/server.d.ts +26 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +729 -638
- package/dist/setup.d.ts +28 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +50 -3
- package/dist/templates/feed.tpl +14 -0
- package/dist/templates/hook.tpl +5 -0
- package/dist/templates/label.tpl +15 -0
- package/dist/templates/og.tpl +17 -0
- package/dist/templates/seed.tpl +11 -0
- package/dist/templates/setup.tpl +5 -0
- package/dist/templates/test-feed.tpl +19 -0
- package/dist/templates/test-xrpc.tpl +19 -0
- package/dist/templates/xrpc.tpl +41 -0
- package/dist/test.d.ts +1 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +41 -32
- package/dist/views.js +1 -1
- package/dist/vite-plugin.d.ts +1 -1
- package/dist/vite-plugin.d.ts.map +1 -1
- package/dist/vite-plugin.js +254 -66
- package/dist/xrpc.d.ts +75 -11
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +189 -39
- package/package.json +17 -9
- package/public/admin-auth.js +271 -306
- package/public/admin.html +177 -61
- package/dist/db.d.ts.map +0 -1
- package/dist/fts.d.ts.map +0 -1
- package/dist/oauth/hooks.d.ts +0 -10
- package/dist/oauth/hooks.d.ts.map +0 -1
- package/dist/oauth/hooks.js +0 -40
- package/dist/schema.d.ts.map +0 -1
- package/dist/test-browser.d.ts +0 -14
- package/dist/test-browser.d.ts.map +0 -1
- 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 {
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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,99 @@ export async function startIndexer(opts) {
|
|
|
220
376
|
const bytes = new Uint8Array(event.data);
|
|
221
377
|
processMessage(bytes, collections);
|
|
222
378
|
}
|
|
223
|
-
catch {
|
|
224
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
390
|
+
/**
|
|
391
|
+
* Handle a `#identity` firehose event for a DID. The `handle` field on the
|
|
392
|
+
* event is optional per the lexicon, and some emitters omit it (signalling
|
|
393
|
+
* "re-resolve"). When absent, we re-resolve from the PLC directory so handle
|
|
394
|
+
* renames propagate even when the relay payload is sparse.
|
|
395
|
+
*
|
|
396
|
+
* Only updates DIDs we already track (present in repoStatusCache) to avoid
|
|
397
|
+
* writing rows for the entire network.
|
|
398
|
+
*/
|
|
399
|
+
export async function handleIdentityEvent(did, payloadHandle) {
|
|
400
|
+
if (!repoStatusCache.has(did))
|
|
238
401
|
return;
|
|
239
|
-
|
|
402
|
+
let handle = payloadHandle;
|
|
403
|
+
const payloadHadHandle = handle !== undefined;
|
|
404
|
+
if (!handle) {
|
|
405
|
+
try {
|
|
406
|
+
// Bound the PLC fetch so a slow plc.directory can't pile up unbounded
|
|
407
|
+
// promises during an identity-event burst (fire-and-forget caller).
|
|
408
|
+
const res = await fetch(`${indexerPlcUrl}/${did}`, {
|
|
409
|
+
signal: AbortSignal.timeout(indexerFetchTimeout * 1000),
|
|
410
|
+
});
|
|
411
|
+
if (res.ok) {
|
|
412
|
+
const doc = (await res.json());
|
|
413
|
+
// First at:// entry is the canonical handle (per @atproto/identity convention)
|
|
414
|
+
const aka = doc.alsoKnownAs?.find((u) => u.startsWith('at://'));
|
|
415
|
+
handle = aka ? aka.slice('at://'.length) : undefined;
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
emit('indexer', 'identity_resolve_error', { did, status: res.status });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
emit('indexer', 'identity_resolve_error', {
|
|
423
|
+
did,
|
|
424
|
+
error: err instanceof Error ? err.message : String(err),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (!handle) {
|
|
429
|
+
emit('indexer', 'identity_no_handle', { did, payload_had_handle: payloadHadHandle });
|
|
240
430
|
return;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
await updateRepoHandle(did, handle);
|
|
434
|
+
emit('indexer', 'identity_handle_update', { did, handle, payload_had_handle: payloadHadHandle });
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
emit('indexer', 'identity_update_error', {
|
|
438
|
+
did,
|
|
439
|
+
handle,
|
|
440
|
+
error: err instanceof Error ? err.message : String(err),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Whether a collection's records may be written from network data.
|
|
446
|
+
*
|
|
447
|
+
* Private collections are AppView-authoritative: nothing on the network may
|
|
448
|
+
* write or delete their rows, so stream ops naming them are spoofed by
|
|
449
|
+
* definition and must be dropped regardless of which source delivered them.
|
|
450
|
+
*/
|
|
451
|
+
export function isIndexableCollection(collection, collections) {
|
|
452
|
+
return collections.has(collection) && !isPrivateCollection(collection);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Apply a repo's commit ops to the index.
|
|
456
|
+
*
|
|
457
|
+
* Wire-agnostic: the relay path reaches here after CBOR/CAR decoding, the
|
|
458
|
+
* Jetstream path after parsing JSON. Callers must have already filtered `ops`
|
|
459
|
+
* through {@link isIndexableCollection}.
|
|
460
|
+
*
|
|
461
|
+
* Handles auto-backfill signalling, buffering for DIDs mid-backfill, lexicon
|
|
462
|
+
* validation, and delete/put routing.
|
|
463
|
+
*/
|
|
464
|
+
export function applyCommit(did, ops) {
|
|
465
|
+
if (ops.length === 0)
|
|
246
466
|
return;
|
|
247
467
|
// When repos are pinned, only process events from those DIDs
|
|
248
468
|
if (indexerPinnedRepos && !indexerPinnedRepos.has(did))
|
|
249
469
|
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
470
|
// Only auto-backfill when we see activity in a signal collection
|
|
257
|
-
const hasSignalOp =
|
|
471
|
+
const hasSignalOp = ops.some((op) => indexerSignalCollections.has(op.collection));
|
|
258
472
|
// Use in-memory cache only — never hit DB from the hot path.
|
|
259
473
|
// Unknown DIDs stay unknown until backfill or auto-backfill discovers them.
|
|
260
474
|
// The cache is populated by triggerAutoBackfill and setRepoStatus calls.
|
|
@@ -263,8 +477,8 @@ function processMessage(bytes, collections) {
|
|
|
263
477
|
if (cachedStatus === undefined) {
|
|
264
478
|
repoStatusCache.set(did, 'unknown');
|
|
265
479
|
}
|
|
266
|
-
if (hasSignalOp
|
|
267
|
-
if (repoStatus === null && backfillInFlight.size <
|
|
480
|
+
if (hasSignalOp) {
|
|
481
|
+
if (repoStatus === null && backfillInFlight.size < maxConcurrentBackfills) {
|
|
268
482
|
repoStatusCache.set(did, 'pending');
|
|
269
483
|
triggerAutoBackfill(did);
|
|
270
484
|
}
|
|
@@ -274,43 +488,101 @@ function processMessage(bytes, collections) {
|
|
|
274
488
|
}
|
|
275
489
|
}
|
|
276
490
|
// For non-signal ops (e.g. profile updates), only process if this DID is already tracked
|
|
277
|
-
if (!hasSignalOp)
|
|
278
|
-
|
|
279
|
-
|
|
491
|
+
if (!hasSignalOp && repoStatus === null)
|
|
492
|
+
return;
|
|
493
|
+
for (const op of ops) {
|
|
494
|
+
const uri = `at://${did}/${op.collection}/${op.rkey}`;
|
|
495
|
+
if (op.action === 'delete') {
|
|
496
|
+
deleteRecord(op.collection, uri);
|
|
497
|
+
fireOnCommitHooks([
|
|
498
|
+
{
|
|
499
|
+
action: 'delete',
|
|
500
|
+
collection: op.collection,
|
|
501
|
+
uri,
|
|
502
|
+
authorDid: did,
|
|
503
|
+
record: null,
|
|
504
|
+
},
|
|
505
|
+
]);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const record = op.record;
|
|
509
|
+
if (!op.cid || !record)
|
|
510
|
+
continue;
|
|
511
|
+
if (record.$type !== op.collection)
|
|
512
|
+
continue;
|
|
513
|
+
const validationError = validateRecord(getLexiconArray(), op.collection, record);
|
|
514
|
+
if (validationError) {
|
|
515
|
+
emit('indexer', 'validation_skip', {
|
|
516
|
+
uri,
|
|
517
|
+
collection: op.collection,
|
|
518
|
+
path: validationError.path,
|
|
519
|
+
error: validationError.message,
|
|
520
|
+
});
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const item = { collection: op.collection, uri, cid: op.cid, authorDid: did, record };
|
|
524
|
+
// If DID is mid-backfill, buffer instead of writing directly
|
|
525
|
+
if (pendingBuffers.has(did)) {
|
|
526
|
+
pendingBuffers.get(did).push(item);
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
bufferWrite(item);
|
|
530
|
+
}
|
|
280
531
|
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Process a single firehose message. Decodes the CBOR header/body, filters
|
|
535
|
+
* for relevant collections, validates records against lexicons, and routes
|
|
536
|
+
* writes to the buffer (or pending buffer if the DID is mid-backfill).
|
|
537
|
+
*/
|
|
538
|
+
export function processMessage(bytes, collections) {
|
|
539
|
+
const header = cborDecode(bytes, 0);
|
|
540
|
+
const body = cborDecode(bytes, header.offset);
|
|
541
|
+
// Handle identity events (handle changes). Fire-and-forget — keeps
|
|
542
|
+
// processMessage synchronous so the WS event loop drains without backpressure.
|
|
543
|
+
if (header.value.t === '#identity') {
|
|
544
|
+
const did = typeof body.value.did === 'string' ? body.value.did : undefined;
|
|
545
|
+
const handle = typeof body.value.handle === 'string' ? body.value.handle : undefined;
|
|
546
|
+
if (did)
|
|
547
|
+
handleIdentityEvent(did, handle);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (header.value.op !== 1 || header.value.t !== '#commit')
|
|
551
|
+
return;
|
|
552
|
+
if (!body.value.blocks || !body.value.ops)
|
|
553
|
+
return;
|
|
554
|
+
// Track sequence number for cursor
|
|
555
|
+
if (body.value.seq)
|
|
556
|
+
noteSeq(body.value.seq);
|
|
557
|
+
const did = body.value.repo;
|
|
558
|
+
if (!did)
|
|
559
|
+
return;
|
|
560
|
+
// Check if any ops in this commit are for collections we care about, before
|
|
561
|
+
// paying for the CAR parse below.
|
|
562
|
+
const relevantOps = body.value.ops.filter((op) => isIndexableCollection(op.path.split('/')[0], collections));
|
|
563
|
+
if (relevantOps.length === 0)
|
|
564
|
+
return;
|
|
565
|
+
// Copy blocks out of the original buffer before it can be GC'd
|
|
566
|
+
const { blocks } = parseCarFrame(new Uint8Array(body.value.blocks));
|
|
567
|
+
const ops = [];
|
|
281
568
|
for (const op of relevantOps) {
|
|
282
569
|
const collection = op.path.split('/')[0];
|
|
283
|
-
const
|
|
570
|
+
const rkey = op.path.split('/').slice(1).join('/');
|
|
284
571
|
if (op.action === 'delete') {
|
|
285
|
-
|
|
572
|
+
ops.push({ action: 'delete', collection, rkey });
|
|
286
573
|
continue;
|
|
287
574
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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 { }
|
|
575
|
+
const opCid = typeof op.cid === 'string' ? op.cid : op.cid?.$link;
|
|
576
|
+
if (!opCid)
|
|
577
|
+
continue;
|
|
578
|
+
const data = blocks.get(opCid);
|
|
579
|
+
if (!data)
|
|
580
|
+
continue;
|
|
581
|
+
try {
|
|
582
|
+
const { value: record } = cborDecode(data);
|
|
583
|
+
ops.push({ action: op.action === 'update' ? 'update' : 'create', collection, rkey, cid: opCid, record });
|
|
314
584
|
}
|
|
585
|
+
catch { }
|
|
315
586
|
}
|
|
587
|
+
applyCommit(did, ops);
|
|
316
588
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jetstream v2 live tail — an alternative stream source to the relay firehose.
|
|
3
|
+
*
|
|
4
|
+
* Where `com.atproto.sync.subscribeRepos` ships every commit on the network as
|
|
5
|
+
* DAG-CBOR frames wrapping a CAR block store, Jetstream filters server-side and
|
|
6
|
+
* delivers records as already-decoded JSON. For an AppView tracking a handful
|
|
7
|
+
* of collections that is the difference between decoding the whole network and
|
|
8
|
+
* decoding only what it indexes.
|
|
9
|
+
*
|
|
10
|
+
* What this trades away: the CAR/MST proofs. hatk never verified them — it
|
|
11
|
+
* already trusts the relay — so nothing is lost that was being used. The
|
|
12
|
+
* `getRepo` backfill path in backfill.ts still speaks CAR and is unaffected.
|
|
13
|
+
*
|
|
14
|
+
* @see https://bsky.network/docs/jetstream
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import { type CommitOp, type IndexerCoreOpts } from './indexer.ts';
|
|
18
|
+
/** Server-side filter caps, rejected before the WebSocket upgrade. */
|
|
19
|
+
export declare const MAX_COLLECTIONS = 100;
|
|
20
|
+
export declare const MAX_DIDS = 10000;
|
|
21
|
+
export interface JetstreamOpts extends IndexerCoreOpts {
|
|
22
|
+
/** Instance base URL, e.g. `wss://jetstream.us-east.bsky.network`. */
|
|
23
|
+
jetstreamUrl: string;
|
|
24
|
+
cursor?: string | null;
|
|
25
|
+
}
|
|
26
|
+
/** A `#commit` payload as delivered by Jetstream v2. */
|
|
27
|
+
interface JetstreamCommit {
|
|
28
|
+
did?: string;
|
|
29
|
+
seq?: number;
|
|
30
|
+
operation?: string;
|
|
31
|
+
collection?: string;
|
|
32
|
+
rkey?: string;
|
|
33
|
+
cid?: string;
|
|
34
|
+
record?: Record<string, any>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build the subscribe URL.
|
|
38
|
+
*
|
|
39
|
+
* Collections are sent explicitly rather than as an `ns.*` wildcard: a wildcard
|
|
40
|
+
* would also match sibling NSIDs the app has no lexicon for (and, for private
|
|
41
|
+
* collections, ones it must never accept from the network).
|
|
42
|
+
*
|
|
43
|
+
* Exported for tests.
|
|
44
|
+
*/
|
|
45
|
+
export declare function buildSubscribeUrl(jetstreamUrl: string, collections: Set<string>, pinnedRepos: Set<string> | null, cursor?: string | null): string;
|
|
46
|
+
/**
|
|
47
|
+
* Reject filters the server would reject at the handshake, where the message
|
|
48
|
+
* can name the actual limit instead of surfacing as a failed connection.
|
|
49
|
+
* Exported for tests.
|
|
50
|
+
*/
|
|
51
|
+
export declare function assertFilterLimits(collections: Set<string>, pinnedRepos: Set<string> | null): void;
|
|
52
|
+
/**
|
|
53
|
+
* Translate one Jetstream commit payload into hatk's wire-agnostic op shape.
|
|
54
|
+
*
|
|
55
|
+
* Deletes carry no `record` or `cid` — only the collection and rkey that
|
|
56
|
+
* identify what went away. Returns `null` when the payload is unusable or
|
|
57
|
+
* names a collection this AppView must not accept from the network.
|
|
58
|
+
*
|
|
59
|
+
* Exported for tests.
|
|
60
|
+
*/
|
|
61
|
+
export declare function commitToOp(commit: JetstreamCommit, collections: Set<string>): CommitOp | null;
|
|
62
|
+
/**
|
|
63
|
+
* Handle one decoded frame. Kept synchronous so the socket's event queue drains
|
|
64
|
+
* without backpressure — identity resolution is fire-and-forget, matching the
|
|
65
|
+
* relay path.
|
|
66
|
+
*
|
|
67
|
+
* Exported for tests.
|
|
68
|
+
*/
|
|
69
|
+
export declare function processEvent(payload: any, collections: Set<string>): void;
|
|
70
|
+
/**
|
|
71
|
+
* Connect to a Jetstream v2 instance and begin indexing.
|
|
72
|
+
*
|
|
73
|
+
* Reconnects on disconnect after {@link RECONNECT_DELAY_MS}, resuming from the
|
|
74
|
+
* highest seq seen rather than the boot-time cursor. Jetstream's cursor is
|
|
75
|
+
* inclusive and delivery is at-least-once, so the event at the resume point
|
|
76
|
+
* arrives again — harmless, since writes upsert on the record's `at://` URI.
|
|
77
|
+
*
|
|
78
|
+
* @returns The WebSocket connection (for shutdown coordination)
|
|
79
|
+
*/
|
|
80
|
+
export declare function startJetstreamIndexer(opts: JetstreamOpts): Promise<WebSocket>;
|
|
81
|
+
export {};
|
|
82
|
+
//# sourceMappingURL=jetstream.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jetstream.d.ts","sourceRoot":"","sources":["../src/jetstream.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EASL,KAAK,QAAQ,EACb,KAAK,eAAe,EACrB,MAAM,cAAc,CAAA;AAGrB,sEAAsE;AACtE,eAAO,MAAM,eAAe,MAAM,CAAA;AAClC,eAAO,MAAM,QAAQ,QAAS,CAAA;AAO9B,MAAM,WAAW,aAAc,SAAQ,eAAe;IACpD,sEAAsE;IACtE,YAAY,EAAE,MAAM,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,wDAAwD;AACxD,UAAU,eAAe;IACvB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAC7B;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EACxB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,EAC/B,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,GACrB,MAAM,CASR;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAAG,IAAI,CAalG;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,eAAe,EAAE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,QAAQ,GAAG,IAAI,CAY7F;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,GAAG,EAAE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAuBzE;AAED;;;;;;;;;GASG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,SAAS,CAAC,CAuCnF"}
|