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