@hatk/hatk 0.0.1-alpha.70 → 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/config.d.ts +15 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/indexer.d.ts +67 -4
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +139 -75
- package/dist/jetstream.d.ts +82 -0
- package/dist/jetstream.d.ts.map +1 -0
- package/dist/jetstream.js +161 -0
- package/dist/main.js +14 -5
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -45,8 +45,21 @@ export interface CdnConfig {
|
|
|
45
45
|
key: string;
|
|
46
46
|
salt: string;
|
|
47
47
|
}
|
|
48
|
+
export interface JetstreamConfig {
|
|
49
|
+
/** Instance base URL, e.g. `wss://jetstream.us-east.bsky.network`. */
|
|
50
|
+
url: string;
|
|
51
|
+
}
|
|
48
52
|
export interface HatkConfig {
|
|
49
53
|
relay: string;
|
|
54
|
+
/**
|
|
55
|
+
* Consume the stream from a Jetstream v2 instance instead of `relay`.
|
|
56
|
+
*
|
|
57
|
+
* Jetstream filters server-side and delivers records as decoded JSON, so an
|
|
58
|
+
* AppView tracking a few collections stops paying to decode the whole
|
|
59
|
+
* network. Not every deployment has one — a local PDS or self-hosted relay
|
|
60
|
+
* won't — so `relay` stays the default.
|
|
61
|
+
*/
|
|
62
|
+
jetstream: JetstreamConfig | null;
|
|
50
63
|
plc: string;
|
|
51
64
|
port: number;
|
|
52
65
|
cdn: CdnConfig | null;
|
|
@@ -62,13 +75,14 @@ export interface HatkConfig {
|
|
|
62
75
|
admins: string[];
|
|
63
76
|
}
|
|
64
77
|
/** Input type for defineConfig — fields that have defaults are optional. */
|
|
65
|
-
export type HatkConfigInput = Partial<Omit<HatkConfig, 'oauth' | 'backfill' | 'push' | 'cdn'>> & {
|
|
78
|
+
export type HatkConfigInput = Partial<Omit<HatkConfig, 'oauth' | 'backfill' | 'push' | 'cdn' | 'jetstream'>> & {
|
|
66
79
|
cdn?: CdnConfig | null;
|
|
67
80
|
oauth?: (Partial<OAuthConfig> & {
|
|
68
81
|
clients: OAuthClientConfig[];
|
|
69
82
|
}) | null;
|
|
70
83
|
backfill?: Partial<BackfillConfig>;
|
|
71
84
|
push?: PushConfig | null;
|
|
85
|
+
jetstream?: JetstreamConfig | null;
|
|
72
86
|
};
|
|
73
87
|
/** Identity function that provides type inference for hatk config files. */
|
|
74
88
|
export declare function defineConfig(config: HatkConfigInput): HatkConfigInput;
|
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAA;IACrC,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,CAAA;IACnC,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;IAC1C,OAAO,CAAC,EAAE,WAAW,EAAE,CAAA;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;IACnB,aAAa,EAAE,MAAM,EAAE,CAAA;IACvB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,OAAO,EAAE,iBAAiB,EAAE,CAAA;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC5B,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;IAChB,WAAW,EAAE,OAAO,CAAA;IACpB,WAAW,EAAE,MAAM,CAAA;IACnB,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,cAAc,CAAA;CACrB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,SAAS,GAAG,IAAI,CAAA;IACrB,cAAc,EAAE,QAAQ,GAAG,QAAQ,CAAA;IACnC,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,kBAAkB,EAAE,MAAM,EAAE,CAAA;IAC5B,QAAQ,EAAE,cAAc,CAAA;IACxB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,KAAK,EAAE,WAAW,GAAG,IAAI,CAAA;IACzB,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;IACvB,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AAED,4EAA4E;AAC5E,MAAM,MAAM,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,UAAU,EAAE,OAAO,GAAG,UAAU,GAAG,MAAM,GAAG,KAAK,CAAC,CAAC,GAAG;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
package/dist/config.js
CHANGED
|
@@ -40,6 +40,7 @@ export async function loadConfig(configPath) {
|
|
|
40
40
|
const database = env.DATABASE || parsed.database;
|
|
41
41
|
const config = {
|
|
42
42
|
relay: env.RELAY || parsed.relay || 'ws://localhost:2583',
|
|
43
|
+
jetstream: env.JETSTREAM_URL ? { url: env.JETSTREAM_URL } : parsed.jetstream || null,
|
|
43
44
|
plc: env.DID_PLC_URL || parsed.plc || 'https://plc.directory',
|
|
44
45
|
port: parseInt(env.PORT || '') || parsed.port || 3000,
|
|
45
46
|
databaseEngine: (env.DATABASE_ENGINE || parsed.databaseEngine || 'sqlite'),
|
package/dist/indexer.d.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
+
/** A single normalized repo operation, independent of the wire it arrived on. */
|
|
2
|
+
export interface CommitOp {
|
|
3
|
+
action: 'create' | 'update' | 'delete';
|
|
4
|
+
collection: string;
|
|
5
|
+
rkey: string;
|
|
6
|
+
/** Absent on deletes. */
|
|
7
|
+
cid?: string;
|
|
8
|
+
/** Absent on deletes. */
|
|
9
|
+
record?: Record<string, any>;
|
|
10
|
+
}
|
|
1
11
|
/** Record the latest firehose sequence seen. Source of truth for cursor persistence and reconnect resume. */
|
|
2
12
|
export declare function noteSeq(seq: number): void;
|
|
13
|
+
/** The highest seq this process has seen, or null before the first event. */
|
|
14
|
+
export declare function getLastSeq(): number | null;
|
|
3
15
|
/**
|
|
4
16
|
* Persist the latest firehose sequence when it has advanced past the stored
|
|
5
17
|
* cursor. Runs on a timer independent of the write path: an app whose
|
|
@@ -9,6 +21,8 @@ export declare function noteSeq(seq: number): void;
|
|
|
9
21
|
* (~135 GB/hour observed against bsky.network) on every boot and reconnect.
|
|
10
22
|
*/
|
|
11
23
|
export declare function checkpointCursor(): Promise<void>;
|
|
24
|
+
/** Point cursor persistence at a stream's own `_cursor` row. See {@link cursorKey}. */
|
|
25
|
+
export declare function setCursorKey(key: 'relay' | 'jetstream'): void;
|
|
12
26
|
/**
|
|
13
27
|
* The cursor a reconnect should resume from: the latest seq this process has
|
|
14
28
|
* seen, falling back to the boot-time cursor before any message has arrived.
|
|
@@ -18,6 +32,12 @@ export declare function checkpointCursor(): Promise<void>;
|
|
|
18
32
|
export declare function resumeCursor(liveSeq: number | null, bootCursor: string | null | undefined): string | null | undefined;
|
|
19
33
|
/** Reset module cursor state between tests. */
|
|
20
34
|
export declare function _resetCursorStateForTests(): void;
|
|
35
|
+
/**
|
|
36
|
+
* Drain the write buffer synchronously instead of waiting out
|
|
37
|
+
* FLUSH_INTERVAL_MS. Lets end-to-end tests assert on rows immediately after
|
|
38
|
+
* feeding a frame.
|
|
39
|
+
*/
|
|
40
|
+
export declare function _flushForTests(): Promise<void>;
|
|
21
41
|
/**
|
|
22
42
|
* Auto-backfill a DID's repo when first seen on the firehose.
|
|
23
43
|
*
|
|
@@ -29,19 +49,26 @@ export declare function _resetCursorStateForTests(): void;
|
|
|
29
49
|
/** Wait for a DID's backfill to complete if one is in flight. */
|
|
30
50
|
export declare function awaitBackfill(did: string): Promise<void>;
|
|
31
51
|
export declare function triggerAutoBackfill(did: string, attempt?: number): Promise<void>;
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Indexing behaviour shared by every stream source. Both the relay firehose
|
|
54
|
+
* and the Jetstream live tail feed the same buffers, backfill signalling, and
|
|
55
|
+
* cursor machinery — only the wire differs.
|
|
56
|
+
*/
|
|
57
|
+
export interface IndexerCoreOpts {
|
|
35
58
|
plcUrl: string;
|
|
36
59
|
collections: Set<string>;
|
|
37
60
|
signalCollections?: Set<string>;
|
|
38
61
|
pinnedRepos?: Set<string>;
|
|
39
|
-
cursor?: string | null;
|
|
40
62
|
fetchTimeout: number;
|
|
41
63
|
maxRetries: number;
|
|
42
64
|
parallelism?: number;
|
|
43
65
|
ftsRebuildInterval?: number;
|
|
44
66
|
}
|
|
67
|
+
/** Configuration for the relay firehose indexer. */
|
|
68
|
+
interface IndexerOpts extends IndexerCoreOpts {
|
|
69
|
+
relayUrl: string;
|
|
70
|
+
cursor?: string | null;
|
|
71
|
+
}
|
|
45
72
|
/**
|
|
46
73
|
* Connect to the AT Protocol relay firehose and begin indexing.
|
|
47
74
|
*
|
|
@@ -52,6 +79,42 @@ interface IndexerOpts {
|
|
|
52
79
|
*
|
|
53
80
|
* @returns The WebSocket connection (for shutdown coordination)
|
|
54
81
|
*/
|
|
82
|
+
export declare function configureIndexer(opts: IndexerCoreOpts): Promise<void>;
|
|
55
83
|
export declare function startIndexer(opts: IndexerOpts): Promise<WebSocket>;
|
|
84
|
+
/**
|
|
85
|
+
* Handle a `#identity` firehose event for a DID. The `handle` field on the
|
|
86
|
+
* event is optional per the lexicon, and some emitters omit it (signalling
|
|
87
|
+
* "re-resolve"). When absent, we re-resolve from the PLC directory so handle
|
|
88
|
+
* renames propagate even when the relay payload is sparse.
|
|
89
|
+
*
|
|
90
|
+
* Only updates DIDs we already track (present in repoStatusCache) to avoid
|
|
91
|
+
* writing rows for the entire network.
|
|
92
|
+
*/
|
|
93
|
+
export declare function handleIdentityEvent(did: string, payloadHandle: string | undefined): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Whether a collection's records may be written from network data.
|
|
96
|
+
*
|
|
97
|
+
* Private collections are AppView-authoritative: nothing on the network may
|
|
98
|
+
* write or delete their rows, so stream ops naming them are spoofed by
|
|
99
|
+
* definition and must be dropped regardless of which source delivered them.
|
|
100
|
+
*/
|
|
101
|
+
export declare function isIndexableCollection(collection: string, collections: Set<string>): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Apply a repo's commit ops to the index.
|
|
104
|
+
*
|
|
105
|
+
* Wire-agnostic: the relay path reaches here after CBOR/CAR decoding, the
|
|
106
|
+
* Jetstream path after parsing JSON. Callers must have already filtered `ops`
|
|
107
|
+
* through {@link isIndexableCollection}.
|
|
108
|
+
*
|
|
109
|
+
* Handles auto-backfill signalling, buffering for DIDs mid-backfill, lexicon
|
|
110
|
+
* validation, and delete/put routing.
|
|
111
|
+
*/
|
|
112
|
+
export declare function applyCommit(did: string, ops: CommitOp[]): void;
|
|
113
|
+
/**
|
|
114
|
+
* Process a single firehose message. Decodes the CBOR header/body, filters
|
|
115
|
+
* for relevant collections, validates records against lexicons, and routes
|
|
116
|
+
* writes to the buffer (or pending buffer if the DID is mid-backfill).
|
|
117
|
+
*/
|
|
118
|
+
export declare function processMessage(bytes: Uint8Array, collections: Set<string>): void;
|
|
56
119
|
export {};
|
|
57
120
|
//# sourceMappingURL=indexer.d.ts.map
|
package/dist/indexer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"indexer.d.ts","sourceRoot":"","sources":["../src/indexer.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"indexer.d.ts","sourceRoot":"","sources":["../src/indexer.ts"],"names":[],"mappings":"AA8BA,iFAAiF;AACjF,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAA;IACtC,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,yBAAyB;IACzB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,yBAAyB;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAC7B;AA0JD,6GAA6G;AAC7G,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAEzC;AAED,6EAA6E;AAC7E,wBAAgB,UAAU,IAAI,MAAM,GAAG,IAAI,CAE1C;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAStD;AAED,uFAAuF;AACvF,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI,CAE7D;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAErH;AAED,+CAA+C;AAC/C,wBAAgB,yBAAyB,IAAI,IAAI,CAIhD;AAED;;;;GAIG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAMpD;AAED;;;;;;;GAOG;AACH,iEAAiE;AACjE,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGxD;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,SAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CA8EjF;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB,iBAAiB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IAC/B,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACzB,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED,oDAAoD;AACpD,UAAU,WAAY,SAAQ,eAAe;IAC3C,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAyBD;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CA6B3E;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC,CAkCxE;AAED;;;;;;;;GAQG;AACH,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CA4CvG;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,OAAO,CAE3F;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,GAAG,IAAI,CAwE9D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAoDhF"}
|
package/dist/indexer.js
CHANGED
|
@@ -14,6 +14,15 @@ let flushTimer = null;
|
|
|
14
14
|
let lastSeq = null;
|
|
15
15
|
let lastPersistedSeq = null;
|
|
16
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';
|
|
17
26
|
const BATCH_SIZE = 100;
|
|
18
27
|
const FLUSH_INTERVAL_MS = 500;
|
|
19
28
|
const CURSOR_CHECKPOINT_INTERVAL_MS = 5_000;
|
|
@@ -62,7 +71,7 @@ async function flushBuffer() {
|
|
|
62
71
|
if (lastSeq !== null) {
|
|
63
72
|
const seq = lastSeq;
|
|
64
73
|
try {
|
|
65
|
-
await setCursor(
|
|
74
|
+
await setCursor(cursorKey, String(seq));
|
|
66
75
|
lastPersistedSeq = seq;
|
|
67
76
|
}
|
|
68
77
|
catch (err) {
|
|
@@ -143,6 +152,10 @@ function bufferWrite(item) {
|
|
|
143
152
|
export function noteSeq(seq) {
|
|
144
153
|
lastSeq = seq;
|
|
145
154
|
}
|
|
155
|
+
/** The highest seq this process has seen, or null before the first event. */
|
|
156
|
+
export function getLastSeq() {
|
|
157
|
+
return lastSeq;
|
|
158
|
+
}
|
|
146
159
|
/**
|
|
147
160
|
* Persist the latest firehose sequence when it has advanced past the stored
|
|
148
161
|
* cursor. Runs on a timer independent of the write path: an app whose
|
|
@@ -156,13 +169,17 @@ export async function checkpointCursor() {
|
|
|
156
169
|
return;
|
|
157
170
|
const seq = lastSeq;
|
|
158
171
|
try {
|
|
159
|
-
await setCursor(
|
|
172
|
+
await setCursor(cursorKey, String(seq));
|
|
160
173
|
lastPersistedSeq = seq;
|
|
161
174
|
}
|
|
162
175
|
catch (err) {
|
|
163
176
|
emit('indexer', 'cursor_checkpoint_error', { cursor_seq: seq, error: err.message });
|
|
164
177
|
}
|
|
165
178
|
}
|
|
179
|
+
/** Point cursor persistence at a stream's own `_cursor` row. See {@link cursorKey}. */
|
|
180
|
+
export function setCursorKey(key) {
|
|
181
|
+
cursorKey = key;
|
|
182
|
+
}
|
|
166
183
|
/**
|
|
167
184
|
* The cursor a reconnect should resume from: the latest seq this process has
|
|
168
185
|
* seen, falling back to the boot-time cursor before any message has arrived.
|
|
@@ -176,6 +193,19 @@ export function resumeCursor(liveSeq, bootCursor) {
|
|
|
176
193
|
export function _resetCursorStateForTests() {
|
|
177
194
|
lastSeq = null;
|
|
178
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();
|
|
179
209
|
}
|
|
180
210
|
/**
|
|
181
211
|
* Auto-backfill a DID's repo when first seen on the firehose.
|
|
@@ -297,14 +327,13 @@ function startMemoryDiagnostics() {
|
|
|
297
327
|
*
|
|
298
328
|
* @returns The WebSocket connection (for shutdown coordination)
|
|
299
329
|
*/
|
|
300
|
-
export async function
|
|
301
|
-
const { relayUrl, collections, cursor, fetchTimeout } = opts;
|
|
330
|
+
export async function configureIndexer(opts) {
|
|
302
331
|
if (opts.ftsRebuildInterval != null)
|
|
303
332
|
ftsRebuildInterval = opts.ftsRebuildInterval;
|
|
304
|
-
indexerCollections = collections;
|
|
305
|
-
indexerSignalCollections = opts.signalCollections || collections;
|
|
333
|
+
indexerCollections = opts.collections;
|
|
334
|
+
indexerSignalCollections = opts.signalCollections || opts.collections;
|
|
306
335
|
indexerPinnedRepos = opts.pinnedRepos || null;
|
|
307
|
-
indexerFetchTimeout = fetchTimeout;
|
|
336
|
+
indexerFetchTimeout = opts.fetchTimeout;
|
|
308
337
|
indexerMaxRetries = opts.maxRetries;
|
|
309
338
|
indexerPlcUrl = opts.plcUrl;
|
|
310
339
|
maxConcurrentBackfills = opts.parallelism ?? 3;
|
|
@@ -325,6 +354,11 @@ export async function startIndexer(opts) {
|
|
|
325
354
|
cursorCheckpointTimer = setInterval(() => void checkpointCursor(), CURSOR_CHECKPOINT_INTERVAL_MS);
|
|
326
355
|
cursorCheckpointTimer.unref?.();
|
|
327
356
|
}
|
|
357
|
+
}
|
|
358
|
+
export async function startIndexer(opts) {
|
|
359
|
+
const { relayUrl, collections, cursor } = opts;
|
|
360
|
+
setCursorKey('relay');
|
|
361
|
+
await configureIndexer(opts);
|
|
328
362
|
let wsUrl = `${relayUrl}/xrpc/com.atproto.sync.subscribeRepos`;
|
|
329
363
|
if (cursor) {
|
|
330
364
|
wsUrl += `?cursor=${cursor}`;
|
|
@@ -362,7 +396,7 @@ export async function startIndexer(opts) {
|
|
|
362
396
|
* Only updates DIDs we already track (present in repoStatusCache) to avoid
|
|
363
397
|
* writing rows for the entire network.
|
|
364
398
|
*/
|
|
365
|
-
async function handleIdentityEvent(did, payloadHandle) {
|
|
399
|
+
export async function handleIdentityEvent(did, payloadHandle) {
|
|
366
400
|
if (!repoStatusCache.has(did))
|
|
367
401
|
return;
|
|
368
402
|
let handle = payloadHandle;
|
|
@@ -408,49 +442,33 @@ async function handleIdentityEvent(did, payloadHandle) {
|
|
|
408
442
|
}
|
|
409
443
|
}
|
|
410
444
|
/**
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
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.
|
|
414
450
|
*/
|
|
415
|
-
function
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (
|
|
430
|
-
return;
|
|
431
|
-
// Track sequence number for cursor
|
|
432
|
-
if (body.value.seq)
|
|
433
|
-
noteSeq(body.value.seq);
|
|
434
|
-
const did = body.value.repo;
|
|
435
|
-
if (!did)
|
|
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)
|
|
436
466
|
return;
|
|
437
467
|
// When repos are pinned, only process events from those DIDs
|
|
438
468
|
if (indexerPinnedRepos && !indexerPinnedRepos.has(did))
|
|
439
469
|
return;
|
|
440
|
-
// Check if any ops in this commit are for collections we care about
|
|
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
|
-
});
|
|
448
|
-
if (relevantOps.length === 0)
|
|
449
|
-
return;
|
|
450
|
-
// Copy blocks out of the original buffer before it can be GC'd
|
|
451
|
-
const { blocks } = parseCarFrame(new Uint8Array(body.value.blocks));
|
|
452
470
|
// Only auto-backfill when we see activity in a signal collection
|
|
453
|
-
const hasSignalOp =
|
|
471
|
+
const hasSignalOp = ops.some((op) => indexerSignalCollections.has(op.collection));
|
|
454
472
|
// Use in-memory cache only — never hit DB from the hot path.
|
|
455
473
|
// Unknown DIDs stay unknown until backfill or auto-backfill discovers them.
|
|
456
474
|
// The cache is populated by triggerAutoBackfill and setRepoStatus calls.
|
|
@@ -459,7 +477,7 @@ function processMessage(bytes, collections) {
|
|
|
459
477
|
if (cachedStatus === undefined) {
|
|
460
478
|
repoStatusCache.set(did, 'unknown');
|
|
461
479
|
}
|
|
462
|
-
if (hasSignalOp
|
|
480
|
+
if (hasSignalOp) {
|
|
463
481
|
if (repoStatus === null && backfillInFlight.size < maxConcurrentBackfills) {
|
|
464
482
|
repoStatusCache.set(did, 'pending');
|
|
465
483
|
triggerAutoBackfill(did);
|
|
@@ -470,19 +488,16 @@ function processMessage(bytes, collections) {
|
|
|
470
488
|
}
|
|
471
489
|
}
|
|
472
490
|
// For non-signal ops (e.g. profile updates), only process if this DID is already tracked
|
|
473
|
-
if (!hasSignalOp)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
for (const op of relevantOps) {
|
|
478
|
-
const collection = op.path.split('/')[0];
|
|
479
|
-
const uri = `at://${did}/${op.path}`;
|
|
491
|
+
if (!hasSignalOp && repoStatus === null)
|
|
492
|
+
return;
|
|
493
|
+
for (const op of ops) {
|
|
494
|
+
const uri = `at://${did}/${op.collection}/${op.rkey}`;
|
|
480
495
|
if (op.action === 'delete') {
|
|
481
|
-
deleteRecord(collection, uri);
|
|
496
|
+
deleteRecord(op.collection, uri);
|
|
482
497
|
fireOnCommitHooks([
|
|
483
498
|
{
|
|
484
499
|
action: 'delete',
|
|
485
|
-
collection,
|
|
500
|
+
collection: op.collection,
|
|
486
501
|
uri,
|
|
487
502
|
authorDid: did,
|
|
488
503
|
record: null,
|
|
@@ -490,6 +505,73 @@ function processMessage(bytes, collections) {
|
|
|
490
505
|
]);
|
|
491
506
|
continue;
|
|
492
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
|
+
}
|
|
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 = [];
|
|
568
|
+
for (const op of relevantOps) {
|
|
569
|
+
const collection = op.path.split('/')[0];
|
|
570
|
+
const rkey = op.path.split('/').slice(1).join('/');
|
|
571
|
+
if (op.action === 'delete') {
|
|
572
|
+
ops.push({ action: 'delete', collection, rkey });
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
493
575
|
const opCid = typeof op.cid === 'string' ? op.cid : op.cid?.$link;
|
|
494
576
|
if (!opCid)
|
|
495
577
|
continue;
|
|
@@ -498,27 +580,9 @@ function processMessage(bytes, collections) {
|
|
|
498
580
|
continue;
|
|
499
581
|
try {
|
|
500
582
|
const { value: record } = cborDecode(data);
|
|
501
|
-
|
|
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);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
583
|
+
ops.push({ action: op.action === 'update' ? 'update' : 'create', collection, rkey, cid: opCid, record });
|
|
521
584
|
}
|
|
522
585
|
catch { }
|
|
523
586
|
}
|
|
587
|
+
applyCommit(did, ops);
|
|
524
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"}
|
|
@@ -0,0 +1,161 @@
|
|
|
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 { applyCommit, configureIndexer, getLastSeq, handleIdentityEvent, isIndexableCollection, noteSeq, resumeCursor, setCursorKey, } from "./indexer.js";
|
|
18
|
+
import { log, emit } from "./logger.js";
|
|
19
|
+
/** Server-side filter caps, rejected before the WebSocket upgrade. */
|
|
20
|
+
export const MAX_COLLECTIONS = 100;
|
|
21
|
+
export const MAX_DIDS = 10_000;
|
|
22
|
+
/** Event kinds hatk consumes. `commit` carries records; `identity` drives handle renames. */
|
|
23
|
+
const KINDS = ['commit', 'identity'];
|
|
24
|
+
const RECONNECT_DELAY_MS = 3000;
|
|
25
|
+
/**
|
|
26
|
+
* Build the subscribe URL.
|
|
27
|
+
*
|
|
28
|
+
* Collections are sent explicitly rather than as an `ns.*` wildcard: a wildcard
|
|
29
|
+
* would also match sibling NSIDs the app has no lexicon for (and, for private
|
|
30
|
+
* collections, ones it must never accept from the network).
|
|
31
|
+
*
|
|
32
|
+
* Exported for tests.
|
|
33
|
+
*/
|
|
34
|
+
export function buildSubscribeUrl(jetstreamUrl, collections, pinnedRepos, cursor) {
|
|
35
|
+
const params = new URLSearchParams();
|
|
36
|
+
for (const collection of collections)
|
|
37
|
+
params.append('collections', collection);
|
|
38
|
+
for (const kind of KINDS)
|
|
39
|
+
params.append('kinds', kind);
|
|
40
|
+
if (pinnedRepos) {
|
|
41
|
+
for (const did of pinnedRepos)
|
|
42
|
+
params.append('dids', did);
|
|
43
|
+
}
|
|
44
|
+
if (cursor)
|
|
45
|
+
params.append('cursor', cursor);
|
|
46
|
+
return `${jetstreamUrl}/xrpc/network.bsky.jetstream.subscribeEvents?${params}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Reject filters the server would reject at the handshake, where the message
|
|
50
|
+
* can name the actual limit instead of surfacing as a failed connection.
|
|
51
|
+
* Exported for tests.
|
|
52
|
+
*/
|
|
53
|
+
export function assertFilterLimits(collections, pinnedRepos) {
|
|
54
|
+
if (collections.size > MAX_COLLECTIONS) {
|
|
55
|
+
throw new Error(`Jetstream accepts at most ${MAX_COLLECTIONS} collections, got ${collections.size}. ` +
|
|
56
|
+
`Narrow the indexed collections or use the relay firehose instead.`);
|
|
57
|
+
}
|
|
58
|
+
if (pinnedRepos && pinnedRepos.size > MAX_DIDS) {
|
|
59
|
+
throw new Error(`Jetstream accepts at most ${MAX_DIDS} dids, got ${pinnedRepos.size}. ` +
|
|
60
|
+
`Unpin some repos or use the relay firehose instead.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Translate one Jetstream commit payload into hatk's wire-agnostic op shape.
|
|
65
|
+
*
|
|
66
|
+
* Deletes carry no `record` or `cid` — only the collection and rkey that
|
|
67
|
+
* identify what went away. Returns `null` when the payload is unusable or
|
|
68
|
+
* names a collection this AppView must not accept from the network.
|
|
69
|
+
*
|
|
70
|
+
* Exported for tests.
|
|
71
|
+
*/
|
|
72
|
+
export function commitToOp(commit, collections) {
|
|
73
|
+
const { collection, rkey, operation } = commit;
|
|
74
|
+
if (!collection || !rkey || !operation)
|
|
75
|
+
return null;
|
|
76
|
+
if (!isIndexableCollection(collection, collections))
|
|
77
|
+
return null;
|
|
78
|
+
if (operation === 'delete') {
|
|
79
|
+
return { action: 'delete', collection, rkey };
|
|
80
|
+
}
|
|
81
|
+
if (operation !== 'create' && operation !== 'update')
|
|
82
|
+
return null;
|
|
83
|
+
if (!commit.cid || !commit.record)
|
|
84
|
+
return null;
|
|
85
|
+
return { action: operation, collection, rkey, cid: commit.cid, record: commit.record };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Handle one decoded frame. Kept synchronous so the socket's event queue drains
|
|
89
|
+
* without backpressure — identity resolution is fire-and-forget, matching the
|
|
90
|
+
* relay path.
|
|
91
|
+
*
|
|
92
|
+
* Exported for tests.
|
|
93
|
+
*/
|
|
94
|
+
export function processEvent(payload, collections) {
|
|
95
|
+
const kind = typeof payload?.$type === 'string' ? payload.$type.split('#')[1] : undefined;
|
|
96
|
+
if (kind === 'identity') {
|
|
97
|
+
const did = typeof payload.did === 'string' ? payload.did : undefined;
|
|
98
|
+
const handle = typeof payload.handle === 'string' ? payload.handle : undefined;
|
|
99
|
+
if (did)
|
|
100
|
+
handleIdentityEvent(did, handle);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (kind !== 'commit')
|
|
104
|
+
return;
|
|
105
|
+
// Track sequence for the cursor before filtering: a stream of events for
|
|
106
|
+
// collections we don't index still advances the position we must resume from.
|
|
107
|
+
if (typeof payload.seq === 'number')
|
|
108
|
+
noteSeq(payload.seq);
|
|
109
|
+
const did = typeof payload.did === 'string' ? payload.did : undefined;
|
|
110
|
+
if (!did)
|
|
111
|
+
return;
|
|
112
|
+
const op = commitToOp(payload, collections);
|
|
113
|
+
if (!op)
|
|
114
|
+
return;
|
|
115
|
+
applyCommit(did, [op]);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Connect to a Jetstream v2 instance and begin indexing.
|
|
119
|
+
*
|
|
120
|
+
* Reconnects on disconnect after {@link RECONNECT_DELAY_MS}, resuming from the
|
|
121
|
+
* highest seq seen rather than the boot-time cursor. Jetstream's cursor is
|
|
122
|
+
* inclusive and delivery is at-least-once, so the event at the resume point
|
|
123
|
+
* arrives again — harmless, since writes upsert on the record's `at://` URI.
|
|
124
|
+
*
|
|
125
|
+
* @returns The WebSocket connection (for shutdown coordination)
|
|
126
|
+
*/
|
|
127
|
+
export async function startJetstreamIndexer(opts) {
|
|
128
|
+
const { jetstreamUrl, collections, cursor } = opts;
|
|
129
|
+
const pinnedRepos = opts.pinnedRepos || null;
|
|
130
|
+
assertFilterLimits(collections, pinnedRepos);
|
|
131
|
+
setCursorKey('jetstream');
|
|
132
|
+
await configureIndexer(opts);
|
|
133
|
+
const wsUrl = buildSubscribeUrl(jetstreamUrl, collections, pinnedRepos, cursor);
|
|
134
|
+
if (cursor)
|
|
135
|
+
log(`[jetstream] Resuming from cursor ${cursor}`);
|
|
136
|
+
log(`[jetstream] Connecting to ${jetstreamUrl} (${collections.size} collections)...`);
|
|
137
|
+
// The lexicon default is identical framing, so an empty subprotocol echo is
|
|
138
|
+
// fine; we offer it so the server can pick the JSON arm explicitly.
|
|
139
|
+
const ws = new WebSocket(wsUrl, ['xrpc.v1.json']);
|
|
140
|
+
ws.addEventListener('message', (event) => {
|
|
141
|
+
try {
|
|
142
|
+
if (typeof event.data !== 'string')
|
|
143
|
+
return;
|
|
144
|
+
const frame = JSON.parse(event.data);
|
|
145
|
+
// Events arrive wrapped in an envelope with the event under `payload`.
|
|
146
|
+
if (frame?.payload)
|
|
147
|
+
processEvent(frame.payload, collections);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
emit('jetstream', 'decode_error', { error: err instanceof Error ? err.message : String(err) });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
ws.addEventListener('open', () => log('[jetstream] Connected'));
|
|
154
|
+
ws.addEventListener('close', () => {
|
|
155
|
+
log(`[jetstream] Disconnected, reconnecting in ${RECONNECT_DELAY_MS / 1000}s...`);
|
|
156
|
+
// Read the seq at close time, not connect time — reusing the boot cursor on
|
|
157
|
+
// every reconnect would replay everything received since the process started.
|
|
158
|
+
setTimeout(() => startJetstreamIndexer({ ...opts, cursor: resumeCursor(getLastSeq(), opts.cursor) }), RECONNECT_DELAY_MS);
|
|
159
|
+
});
|
|
160
|
+
return ws;
|
|
161
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -23,6 +23,7 @@ import { initXrpc, listXrpc, configureRelay, configureCdn, configureOAuth, callX
|
|
|
23
23
|
import { initOpengraph } from "./opengraph.js";
|
|
24
24
|
import { initLabels, getLabelDefinitions } from "./labels.js";
|
|
25
25
|
import { startIndexer } from "./indexer.js";
|
|
26
|
+
import { startJetstreamIndexer } from "./jetstream.js";
|
|
26
27
|
import { rebuildAllIndexes } from "./database/fts.js";
|
|
27
28
|
import { createHandler, registerCoreHandlers } from "./server.js";
|
|
28
29
|
import { setPrivateCollections } from "./private-collections.js";
|
|
@@ -206,19 +207,27 @@ log(` Feeds: ${listFeeds()
|
|
|
206
207
|
const ignoreSavedCursor = Boolean(process.env.HATK_IGNORE_SAVED_CURSOR);
|
|
207
208
|
if (ignoreSavedCursor)
|
|
208
209
|
log('[main] HATK_IGNORE_SAVED_CURSOR set — starting indexer from live');
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
// Each source keeps its own cursor row: relay seqs and Jetstream seqs are
|
|
211
|
+
// different coordinate systems, so switching sources must not resume one from
|
|
212
|
+
// the other's value.
|
|
213
|
+
const indexerCore = {
|
|
212
214
|
plcUrl: config.plc,
|
|
213
215
|
collections: collectionSet,
|
|
214
216
|
signalCollections: config.backfill.signalCollections ? new Set(config.backfill.signalCollections) : undefined,
|
|
215
217
|
pinnedRepos: config.backfill.repos ? new Set(config.backfill.repos) : undefined,
|
|
216
|
-
cursor,
|
|
217
218
|
fetchTimeout: config.backfill.fetchTimeout,
|
|
218
219
|
maxRetries: config.backfill.maxRetries,
|
|
219
220
|
parallelism: config.backfill.parallelism,
|
|
220
221
|
ftsRebuildInterval: config.ftsRebuildInterval,
|
|
221
|
-
}
|
|
222
|
+
};
|
|
223
|
+
if (config.jetstream) {
|
|
224
|
+
const cursor = ignoreSavedCursor ? null : await getCursor('jetstream');
|
|
225
|
+
startJetstreamIndexer({ ...indexerCore, jetstreamUrl: config.jetstream.url, cursor });
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const cursor = ignoreSavedCursor ? null : await getCursor('relay');
|
|
229
|
+
startIndexer({ ...indexerCore, relayUrl: config.relay, cursor });
|
|
230
|
+
}
|
|
222
231
|
// 7. Run backfill in background
|
|
223
232
|
runBackfillAndRestart();
|
|
224
233
|
// Graceful shutdown
|