@hatk/hatk 0.0.1-alpha.79 → 0.0.1-alpha.80

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.
@@ -18,6 +18,24 @@ import { type CommitOp, type IndexerCoreOpts } from './indexer.ts';
18
18
  /** Server-side filter caps, rejected before the WebSocket upgrade. */
19
19
  export declare const MAX_COLLECTIONS = 100;
20
20
  export declare const MAX_DIDS = 10000;
21
+ /**
22
+ * Refused handshakes before the next attempt drops its cursor and probes live.
23
+ *
24
+ * Jetstream retains roughly a day of seqs. A cursor older than that is refused
25
+ * at the handshake — a bare close 1006, no `open`, no message. Nothing arrives,
26
+ * so {@link getLastSeq} stays null and a plain resume offers the same dead
27
+ * cursor forever. Restarting does not clear it either: the boot cursor is read
28
+ * back from the row this stream never got to advance. The stream wedges for
29
+ * good, while records written through the AppView's own path keep indexing, so
30
+ * nothing looks broken from inside the app.
31
+ *
32
+ * A cursorless probe tells a dead cursor from a dead instance without guessing:
33
+ * a reachable instance always accepts a cursorless subscribe, so a probe that
34
+ * opens means the cursor was the problem, and a probe that is refused too means
35
+ * the instance is down and the cursor is still worth keeping. Probing every Nth
36
+ * attempt rather than once keeps that true across an outage of any length.
37
+ */
38
+ export declare const CURSOR_PROBE_EVERY = 3;
21
39
  export interface JetstreamOpts extends IndexerCoreOpts {
22
40
  /** Instance base URL, e.g. `wss://jetstream.us-east.bsky.network`. */
23
41
  jetstreamUrl: string;
@@ -67,6 +85,18 @@ export declare function commitToOp(commit: JetstreamCommit, collections: Set<str
67
85
  * Exported for tests.
68
86
  */
69
87
  export declare function processEvent(payload: any, collections: Set<string>): void;
88
+ /**
89
+ * The cursor a connection attempt should offer, given how many attempts in a
90
+ * row were refused before it.
91
+ *
92
+ * `refusals` counts consecutive closes that never reached `open`; it resets the
93
+ * moment one does. Every {@link CURSOR_PROBE_EVERY}th refusal answers null so
94
+ * the attempt subscribes to the live tip instead — see the constant for why
95
+ * that is the discriminator.
96
+ *
97
+ * Exported for tests.
98
+ */
99
+ export declare function reconnectCursor(refusals: number, liveSeq: number | null, bootCursor: string | null | undefined): string | null | undefined;
70
100
  /**
71
101
  * Connect to a Jetstream v2 instance and begin indexing.
72
102
  *
@@ -75,8 +105,14 @@ export declare function processEvent(payload: any, collections: Set<string>): vo
75
105
  * inclusive and delivery is at-least-once, so the event at the resume point
76
106
  * arrives again — harmless, since writes upsert on the record's `at://` URI.
77
107
  *
108
+ * `opts.cursor` is the boot-time cursor and stays fixed across reconnects;
109
+ * what each attempt actually offers comes from {@link reconnectCursor}, so a
110
+ * live probe can drop the cursor for one attempt without losing it.
111
+ *
112
+ * @param refusals Consecutive refused handshakes so far. Internal — reconnects
113
+ * pass their own count; callers start at 0.
78
114
  * @returns The WebSocket connection (for shutdown coordination)
79
115
  */
80
- export declare function startJetstreamIndexer(opts: JetstreamOpts): Promise<WebSocket>;
116
+ export declare function startJetstreamIndexer(opts: JetstreamOpts, refusals?: number): Promise<WebSocket>;
81
117
  export {};
82
118
  //# sourceMappingURL=jetstream.d.ts.map
@@ -1 +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"}
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;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC,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;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,GAAG,IAAI,EACtB,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GACpC,MAAM,GAAG,IAAI,GAAG,SAAS,CAK3B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,aAAa,EAAE,QAAQ,SAAI,GAAG,OAAO,CAAC,SAAS,CAAC,CA0DjG"}
package/dist/jetstream.js CHANGED
@@ -22,6 +22,24 @@ export const MAX_DIDS = 10_000;
22
22
  /** Event kinds hatk consumes. `commit` carries records; `identity` drives handle renames. */
23
23
  const KINDS = ['commit', 'identity'];
24
24
  const RECONNECT_DELAY_MS = 3000;
25
+ /**
26
+ * Refused handshakes before the next attempt drops its cursor and probes live.
27
+ *
28
+ * Jetstream retains roughly a day of seqs. A cursor older than that is refused
29
+ * at the handshake — a bare close 1006, no `open`, no message. Nothing arrives,
30
+ * so {@link getLastSeq} stays null and a plain resume offers the same dead
31
+ * cursor forever. Restarting does not clear it either: the boot cursor is read
32
+ * back from the row this stream never got to advance. The stream wedges for
33
+ * good, while records written through the AppView's own path keep indexing, so
34
+ * nothing looks broken from inside the app.
35
+ *
36
+ * A cursorless probe tells a dead cursor from a dead instance without guessing:
37
+ * a reachable instance always accepts a cursorless subscribe, so a probe that
38
+ * opens means the cursor was the problem, and a probe that is refused too means
39
+ * the instance is down and the cursor is still worth keeping. Probing every Nth
40
+ * attempt rather than once keeps that true across an outage of any length.
41
+ */
42
+ export const CURSOR_PROBE_EVERY = 3;
25
43
  /**
26
44
  * Build the subscribe URL.
27
45
  *
@@ -114,6 +132,24 @@ export function processEvent(payload, collections) {
114
132
  return;
115
133
  applyCommit(did, [op]);
116
134
  }
135
+ /**
136
+ * The cursor a connection attempt should offer, given how many attempts in a
137
+ * row were refused before it.
138
+ *
139
+ * `refusals` counts consecutive closes that never reached `open`; it resets the
140
+ * moment one does. Every {@link CURSOR_PROBE_EVERY}th refusal answers null so
141
+ * the attempt subscribes to the live tip instead — see the constant for why
142
+ * that is the discriminator.
143
+ *
144
+ * Exported for tests.
145
+ */
146
+ export function reconnectCursor(refusals, liveSeq, bootCursor) {
147
+ const resume = resumeCursor(liveSeq, bootCursor);
148
+ // Nothing to abandon — an attempt with no cursor is already a live tail.
149
+ if (!resume)
150
+ return resume;
151
+ return refusals > 0 && refusals % CURSOR_PROBE_EVERY === 0 ? null : resume;
152
+ }
117
153
  /**
118
154
  * Connect to a Jetstream v2 instance and begin indexing.
119
155
  *
@@ -122,17 +158,26 @@ export function processEvent(payload, collections) {
122
158
  * inclusive and delivery is at-least-once, so the event at the resume point
123
159
  * arrives again — harmless, since writes upsert on the record's `at://` URI.
124
160
  *
161
+ * `opts.cursor` is the boot-time cursor and stays fixed across reconnects;
162
+ * what each attempt actually offers comes from {@link reconnectCursor}, so a
163
+ * live probe can drop the cursor for one attempt without losing it.
164
+ *
165
+ * @param refusals Consecutive refused handshakes so far. Internal — reconnects
166
+ * pass their own count; callers start at 0.
125
167
  * @returns The WebSocket connection (for shutdown coordination)
126
168
  */
127
- export async function startJetstreamIndexer(opts) {
128
- const { jetstreamUrl, collections, cursor } = opts;
169
+ export async function startJetstreamIndexer(opts, refusals = 0) {
170
+ const { jetstreamUrl, collections } = opts;
129
171
  const pinnedRepos = opts.pinnedRepos || null;
130
172
  assertFilterLimits(collections, pinnedRepos);
131
173
  setCursorKey('jetstream');
132
174
  await configureIndexer(opts);
175
+ const cursor = reconnectCursor(refusals, getLastSeq(), opts.cursor);
133
176
  const wsUrl = buildSubscribeUrl(jetstreamUrl, collections, pinnedRepos, cursor);
134
177
  if (cursor)
135
178
  log(`[jetstream] Resuming from cursor ${cursor}`);
179
+ else if (refusals > 0)
180
+ log('[jetstream] Probing the live tip — the resume cursor keeps being refused');
136
181
  log(`[jetstream] Connecting to ${jetstreamUrl} (${collections.size} collections)...`);
137
182
  // The lexicon default is identical framing, so an empty subprotocol echo is
138
183
  // fine; we offer it so the server can pick the JSON arm explicitly.
@@ -150,12 +195,31 @@ export async function startJetstreamIndexer(opts) {
150
195
  emit('jetstream', 'decode_error', { error: err instanceof Error ? err.message : String(err) });
151
196
  }
152
197
  });
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);
198
+ let opened = false;
199
+ ws.addEventListener('open', () => {
200
+ opened = true;
201
+ log('[jetstream] Connected');
202
+ });
203
+ ws.addEventListener('close', (event) => {
204
+ // A close that never reached `open` is a refused handshake, not a dropped
205
+ // stream, and the two want opposite things from the cursor.
206
+ const nextRefusals = opened ? 0 : refusals + 1;
207
+ if (opened) {
208
+ log(`[jetstream] Disconnected (${event.code}), reconnecting in ${RECONNECT_DELAY_MS / 1000}s...`);
209
+ }
210
+ else {
211
+ log(`[jetstream] Handshake refused (${event.code}${event.reason ? `: ${event.reason}` : ''}) ` +
212
+ `x${nextRefusals}, retrying in ${RECONNECT_DELAY_MS / 1000}s...`);
213
+ emit('jetstream', 'handshake_refused', {
214
+ code: event.code,
215
+ reason: event.reason || undefined,
216
+ cursor: cursor ?? null,
217
+ refusals: nextRefusals,
218
+ });
219
+ }
220
+ // `opts` goes back unchanged so the boot cursor survives a probe; the seq
221
+ // is read at reconnect time, not here, so a resume uses everything seen.
222
+ setTimeout(() => startJetstreamIndexer(opts, nextRefusals), RECONNECT_DELAY_MS);
159
223
  });
160
224
  return ws;
161
225
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatk/hatk",
3
- "version": "0.0.1-alpha.79",
3
+ "version": "0.0.1-alpha.80",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "hatk": "dist/cli.js"