@effectstream/sync 0.200.5 → 0.200.6

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/README.md CHANGED
@@ -72,7 +72,7 @@ catch-up — in steady state the buffer sits near zero and the cap is never reac
72
72
 
73
73
  **Deadlock-safety (merge-demand exemption).** A naive cap can deadlock the merge: it
74
74
  produces a root block at timestamp `τ` only once a parallel chain's page passes `τ`, and
75
- drains that chain's buffer only *after* the page passes `τ`. If the cap pauses the
75
+ drains that chain's buffer only _after_ the page passes `τ`. If the cap pauses the
76
76
  fetcher while its page is still `≤ τ` (the data up to `τ` exceeds the cap — e.g. a far
77
77
  skip-ahead or a parallel chain finer-grained than the cap), neither side can proceed: a
78
78
  circular wait, not a lost wakeup.
@@ -83,12 +83,13 @@ page (`mergeIntoRoot` sets `SyncState.mergeWaitingForPage`), `bufferAtCap` retur
83
83
  **bounded by necessity** — only `(lastPage.root, τ]` is buffered above the cap, exactly
84
84
  what the merge must hold to build block `τ`; once the page passes `τ` the normal cap
85
85
  re-engages. A chain that is merely tip-limited (caught up, not gated by the merge) is
86
- *not* exempted, so steady-state and head-of-line bounds are unaffected. Reproductions:
86
+ _not_ exempted, so steady-state and head-of-line bounds are unaffected. Reproductions:
87
87
  `buffering.test.ts` 1c (skip-ahead) and 1d (density).
88
88
 
89
89
  **Scope.** The guard runs in **every** chain's `stateToInput`, so all sync chains are
90
90
  covered: EVM, NTP, Bitcoin, Avail, Celestia, NEAR, Solana, Midnight, Cardano (UTXO-RPC), and
91
91
  the synthetic `test` chain. Two notes:
92
+
92
93
  - **Cardano (UTXO-RPC)** has no `stepSize` (it streams one block per pass), so the cap
93
94
  falls back to a default chunk size of 1000 (⇒ default cap 4000); set `maxBufferedPages`
94
95
  explicitly to tune it.
@@ -124,7 +125,7 @@ loop's job (it re-runs the same page range and counts the failure, which is what
124
125
  **Coverage.** Honoured by EVM (as the viem transport timeout), Bitcoin, NEAR,
125
126
  Avail (light-client HTTP), Midnight and Celestia. Two exceptions:
126
127
 
127
- - **Celestia** retries internally, so the value bounds each *attempt* rather than
128
+ - **Celestia** retries internally, so the value bounds each _attempt_ rather than
128
129
  the whole call. It still falls back to `CELESTIA_RPC_TIMEOUT_MS` when no
129
130
  protocol-level value is set.
130
131
  - **Cardano / utxorpc** reads its data from a gRPC stream rather than
@@ -132,7 +133,8 @@ Avail (light-client HTTP), Midnight and Celestia. Two exceptions:
132
133
  protected by producer supervision — a stream that dies or ends is restarted
133
134
  with backoff (see below).
134
135
 
135
- NTP and the synthetic `test` chain make no network calls at all.
136
+ The synthetic `test` chain makes no network calls. NTP performs bounded UDP I/O;
137
+ its one-shot boundary is described below.
136
138
 
137
139
  ```ts
138
140
  .addParallel((n) => n.myChain, () => ({
@@ -144,6 +146,42 @@ NTP and the synthetic `test` chain make no network calls at all.
144
146
  }))
145
147
  ```
146
148
 
149
+ ## NTP clock ownership and one-shot tip
150
+
151
+ Every `NtpFetcher` owns its own `ntp-time-sync` client and cache, including when
152
+ `servers` is absent or empty. A non-empty server list is defensively copied into
153
+ that client. This prevents a default or configured fetcher from reusing another
154
+ fetcher's server selection or cached offset.
155
+
156
+ For startup boundaries, `getNtpTip` builds a private `ntp-time-sync` client
157
+ per call, samples the selected servers concurrently, and returns the inclusive
158
+ page from the first validated round:
159
+
160
+ ```ts
161
+ import { getNtpTip } from "@effectstream/sync";
162
+
163
+ const { height } = await getNtpTip({
164
+ startTime: network.startTime,
165
+ blockTimeMS: network.blockTimeMS,
166
+ servers: network.servers,
167
+ signal,
168
+ // requestTimeoutMs: 15_000, // default
169
+ });
170
+ ```
171
+
172
+ The operation has one deadline (`requestTimeoutMs`), which also bounds the
173
+ client's own socket wait; on timeout or caller abort it clears its timer and
174
+ abort listener and rejects, while the client's in-flight sockets close at that
175
+ same deadline. `NtpTipError.code` distinguishes invalid options, abort,
176
+ timeout, network, and invalid time; abort preserves the caller's reason as
177
+ `cause`, while timeout exposes the effective `timeoutMs`.
178
+
179
+ Compatibility warning: the exact `ntp-time-sync` 0.6.0 upgrade drops Node 18
180
+ support. Its dependency graph requires Node 20.19+ or Node 22.12+ (the repository's
181
+ supported Bun runtime also works). Version 0.6.0 also corrects offsets to their
182
+ signed RFC 5905 meaning, so both the runtime floor and clock-direction behavior
183
+ are breaking compatibility changes.
184
+
147
185
  ## Producer supervision (streaming chains)
148
186
 
149
187
  Chains whose data arrives over a subscription rather than by polling (utxorpc
@@ -181,6 +219,34 @@ application code drives them through `genSyncProtocols` rather than
181
219
  instantiating them directly. Reach for them only if you're writing a
182
220
  custom orchestration layer.
183
221
 
222
+ ## One-shot Midnight tip
223
+
224
+ `getMidnightTip()` performs one bounded query for the Midnight indexer's
225
+ current block height. The caller must supply the absolute HTTP(S) `indexer`;
226
+ the helper has no network or endpoint default.
227
+
228
+ ```typescript
229
+ import { getMidnightTip } from "@effectstream/sync";
230
+
231
+ const { height } = await getMidnightTip({
232
+ indexer: midnight.indexer,
233
+ requestTimeoutMs: 15_000, // optional; 15 seconds by default
234
+ signal,
235
+ });
236
+ ```
237
+
238
+ The operation sends exactly one `query { block { height } }` POST and returns
239
+ one validated non-negative safe integer. It never retries, polls, caches, or
240
+ persists a partial or successful result.
241
+
242
+ Failures are `MidnightTipError` instances with stable codes:
243
+ `INVALID_OPTIONS`, `ABORTED`, `TIMEOUT`, `NETWORK`, `HTTP`, `GRAPHQL`, and
244
+ `INVALID_RESPONSE`. Caller abort preserves `signal.reason`; network and JSON
245
+ parse failures preserve their original cause; HTTP failures expose
246
+ `status`/`statusText`; and GraphQL failures expose a frozen `graphqlErrors`
247
+ copy. Caller abort and the operation deadline use first-winner semantics and
248
+ release their timer, listener, and request on every settlement.
249
+
184
250
  ## Examples
185
251
 
186
252
  End-to-end sync test (boots a node, reads blocks, asserts the DB):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/sync",
3
- "version": "0.200.5",
3
+ "version": "0.200.6",
4
4
  "license": "(MIT OR Apache-2.0)",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -13,16 +13,16 @@
13
13
  },
14
14
  "exports": "./src/mod.ts",
15
15
  "dependencies": {
16
- "@effectstream/config": "0.200.5",
17
- "@effectstream/db": "0.200.5",
18
- "@effectstream/log": "0.200.5",
19
- "@effectstream/utils": "0.200.5",
16
+ "@effectstream/config": "0.200.6",
17
+ "@effectstream/db": "0.200.6",
18
+ "@effectstream/log": "0.200.6",
19
+ "@effectstream/utils": "0.200.6",
20
20
  "@midnightntwrk/ledger-v9": "1.0.0-rc.3",
21
21
  "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "5.0.0-beta.6",
22
22
  "@midnight-ntwrk/onchain-runtime": "npm:@midnightntwrk/onchain-runtime-v4@4.0.0-rc.3",
23
23
  "effection": "3.5.0",
24
24
  "graphql-ws": "^6.0.6",
25
- "ntp-time-sync": "^0.5.0",
25
+ "ntp-time-sync": "0.6.0",
26
26
  "viem": "2.37.3",
27
27
  "denque": "^2.1.0",
28
28
  "pg": "^8.14.0",
@@ -0,0 +1,295 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { Value } from "@sinclair/typebox/value";
3
+
4
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
5
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
6
+
7
+ export type GetMidnightTipOptions = {
8
+ indexer: string;
9
+ requestTimeoutMs?: number;
10
+ signal?: AbortSignal;
11
+ };
12
+
13
+ export type MidnightTip = { height: number };
14
+
15
+ export type MidnightTipErrorCode =
16
+ | "INVALID_OPTIONS"
17
+ | "ABORTED"
18
+ | "TIMEOUT"
19
+ | "NETWORK"
20
+ | "HTTP"
21
+ | "GRAPHQL"
22
+ | "INVALID_RESPONSE";
23
+
24
+ export class MidnightTipError extends Error {
25
+ readonly code: MidnightTipErrorCode;
26
+ override readonly cause?: unknown;
27
+ readonly status?: number;
28
+ readonly statusText?: string;
29
+ readonly graphqlErrors?: readonly unknown[];
30
+
31
+ constructor(
32
+ code: MidnightTipErrorCode,
33
+ message: string,
34
+ options: {
35
+ cause?: unknown;
36
+ status?: number;
37
+ statusText?: string;
38
+ graphqlErrors?: readonly unknown[];
39
+ } = {},
40
+ ) {
41
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
42
+ this.name = "MidnightTipError";
43
+ this.code = code;
44
+ this.cause = options.cause;
45
+ this.status = options.status;
46
+ this.statusText = options.statusText;
47
+ this.graphqlErrors = options.graphqlErrors;
48
+ }
49
+ }
50
+
51
+ function validateOptions(options: GetMidnightTipOptions): {
52
+ indexer: string;
53
+ requestTimeoutMs: number;
54
+ signal?: AbortSignal;
55
+ } {
56
+ if (options == null || typeof options !== "object") {
57
+ throw new MidnightTipError("INVALID_OPTIONS", "Options must be an object");
58
+ }
59
+ if (typeof options.indexer !== "string") {
60
+ throw new MidnightTipError(
61
+ "INVALID_OPTIONS",
62
+ "indexer must be an absolute HTTP(S) URL",
63
+ );
64
+ }
65
+
66
+ let url: URL;
67
+ try {
68
+ url = new URL(options.indexer);
69
+ } catch {
70
+ throw new MidnightTipError(
71
+ "INVALID_OPTIONS",
72
+ "indexer must be an absolute HTTP(S) URL",
73
+ );
74
+ }
75
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
76
+ throw new MidnightTipError("INVALID_OPTIONS", "indexer must use HTTP or HTTPS");
77
+ }
78
+
79
+ const requestTimeoutMs =
80
+ options.requestTimeoutMs === undefined
81
+ ? DEFAULT_REQUEST_TIMEOUT_MS
82
+ : options.requestTimeoutMs;
83
+ if (
84
+ !Number.isSafeInteger(requestTimeoutMs) ||
85
+ !Number.isFinite(requestTimeoutMs) ||
86
+ requestTimeoutMs <= 0
87
+ ) {
88
+ throw new MidnightTipError(
89
+ "INVALID_OPTIONS",
90
+ "requestTimeoutMs must be a positive finite safe integer",
91
+ );
92
+ }
93
+
94
+ const signal = options.signal;
95
+ if (
96
+ signal !== undefined &&
97
+ (signal === null ||
98
+ typeof signal !== "object" ||
99
+ typeof signal.addEventListener !== "function" ||
100
+ typeof signal.removeEventListener !== "function" ||
101
+ typeof signal.aborted !== "boolean")
102
+ ) {
103
+ throw new MidnightTipError("INVALID_OPTIONS", "signal must be an AbortSignal");
104
+ }
105
+
106
+ return { indexer: url.href, requestTimeoutMs, signal };
107
+ }
108
+
109
+ function invalidResponse(message: string, cause?: unknown): MidnightTipError {
110
+ return new MidnightTipError("INVALID_RESPONSE", message, { cause });
111
+ }
112
+
113
+ /**
114
+ * Split deadlines at the maximum delay supported consistently by Node and Bun.
115
+ * Larger delays are otherwise clamped and can fire years too early.
116
+ */
117
+ function scheduleDeadline(delayMs: number, onDeadline: () => void): () => void {
118
+ let remainingMs = delayMs;
119
+ let active = true;
120
+ let timeout: ReturnType<typeof setTimeout> | undefined;
121
+
122
+ const scheduleNext = (): void => {
123
+ const chunkMs = Math.min(remainingMs, MAX_TIMER_DELAY_MS);
124
+ remainingMs -= chunkMs;
125
+ timeout = setTimeout(() => {
126
+ if (!active) return;
127
+ timeout = undefined;
128
+ if (remainingMs > 0) {
129
+ scheduleNext();
130
+ return;
131
+ }
132
+ active = false;
133
+ onDeadline();
134
+ }, chunkMs);
135
+ };
136
+
137
+ scheduleNext();
138
+ return () => {
139
+ if (!active) return;
140
+ active = false;
141
+ if (timeout !== undefined) clearTimeout(timeout);
142
+ timeout = undefined;
143
+ };
144
+ }
145
+
146
+ function aborted(reason: unknown): MidnightTipError {
147
+ return new MidnightTipError("ABORTED", "Midnight tip query was aborted", {
148
+ cause: reason,
149
+ });
150
+ }
151
+
152
+ function timedOut(): MidnightTipError {
153
+ return new MidnightTipError("TIMEOUT", "Midnight tip query timed out");
154
+ }
155
+
156
+ /**
157
+ * POST a JSON body and return the parsed JSON response. Owns all the
158
+ * network plumbing — caller abort, request timeout, HTTP-status failures,
159
+ * unread-body cleanup, JSON parsing — and maps every failure to a
160
+ * MidnightTipError so callers only deal with the decoded payload.
161
+ */
162
+ async function postJsonRequest(
163
+ url: string,
164
+ requestBody: unknown,
165
+ requestTimeoutMs: number,
166
+ signal?: AbortSignal,
167
+ ): Promise<unknown> {
168
+ if (signal?.aborted) throw aborted(signal.reason);
169
+
170
+ const requestController = new AbortController();
171
+ let winner: "ABORTED" | "TIMEOUT" | undefined;
172
+ let callerReason: unknown;
173
+
174
+ const onCallerAbort = (): void => {
175
+ if (winner !== undefined) return;
176
+ winner = "ABORTED";
177
+ callerReason = signal?.reason;
178
+ requestController.abort(callerReason);
179
+ };
180
+
181
+ signal?.addEventListener("abort", onCallerAbort, { once: true });
182
+ // Close the listener-registration race before allocating a timer or doing I/O.
183
+ if (signal?.aborted) onCallerAbort();
184
+ if (winner === "ABORTED") {
185
+ signal?.removeEventListener("abort", onCallerAbort);
186
+ throw aborted(callerReason);
187
+ }
188
+
189
+ const clearDeadline = scheduleDeadline(requestTimeoutMs, () => {
190
+ if (winner !== undefined) return;
191
+ winner = "TIMEOUT";
192
+ requestController.abort();
193
+ });
194
+
195
+ const throwIfCancelled = (): void => {
196
+ if (winner === "ABORTED") throw aborted(callerReason);
197
+ if (winner === "TIMEOUT") throw timedOut();
198
+ };
199
+
200
+ try {
201
+ let response: Response;
202
+ try {
203
+ response = await fetch(url, {
204
+ method: "POST",
205
+ body: JSON.stringify(requestBody),
206
+ headers: { "Content-Type": "application/json" },
207
+ redirect: "manual",
208
+ signal: requestController.signal,
209
+ });
210
+ } catch (error) {
211
+ throwIfCancelled();
212
+ throw new MidnightTipError("NETWORK", "Midnight tip query failed", {
213
+ cause: error,
214
+ });
215
+ }
216
+ throwIfCancelled();
217
+
218
+ if (!response.ok) {
219
+ // A fetch resolves after response headers. Explicitly cancel an unread
220
+ // body so an HTTP failure cannot leave its streaming request/socket live.
221
+ try {
222
+ await response.body?.cancel();
223
+ } catch {
224
+ // Classification is based on the already-received HTTP response.
225
+ }
226
+ throwIfCancelled();
227
+ throw new MidnightTipError(
228
+ "HTTP",
229
+ `Midnight indexer returned ${response.status} ${response.statusText}`,
230
+ { status: response.status, statusText: response.statusText },
231
+ );
232
+ }
233
+
234
+ let body: unknown;
235
+ try {
236
+ body = await response.json();
237
+ } catch (error) {
238
+ throwIfCancelled();
239
+ throw invalidResponse("Midnight indexer returned malformed JSON", error);
240
+ }
241
+ throwIfCancelled();
242
+
243
+ return body;
244
+ } finally {
245
+ clearDeadline();
246
+ signal?.removeEventListener("abort", onCallerAbort);
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Checked before the tip shape so a non-empty errors array classifies as
252
+ * GRAPHQL even when data is absent, and a malformed errors field (or a
253
+ * non-object body) classifies as INVALID_RESPONSE.
254
+ */
255
+ const GraphQLEnvelopeSchema = Type.Object({
256
+ errors: Type.Optional(Type.Array(Type.Unknown())),
257
+ });
258
+
259
+ const TipResponseSchema = Type.Object({
260
+ data: Type.Object({
261
+ block: Type.Object({
262
+ // minimum + maximum encode Number.isSafeInteger(height) && height >= 0.
263
+ height: Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
264
+ }),
265
+ }),
266
+ });
267
+
268
+ /** Query one validated numeric block height from an explicit Midnight indexer. */
269
+ export async function getMidnightTip(
270
+ options: GetMidnightTipOptions,
271
+ ): Promise<MidnightTip> {
272
+ const validated = validateOptions(options);
273
+ const body = await postJsonRequest(
274
+ validated.indexer,
275
+ { query: "query { block { height } }" },
276
+ validated.requestTimeoutMs,
277
+ validated.signal,
278
+ );
279
+
280
+ if (!Value.Check(GraphQLEnvelopeSchema, body)) {
281
+ throw invalidResponse("Midnight indexer returned a malformed GraphQL response");
282
+ }
283
+
284
+ if (body.errors !== undefined && body.errors.length > 0) {
285
+ throw new MidnightTipError("GRAPHQL", "Midnight indexer returned GraphQL errors", {
286
+ graphqlErrors: Object.freeze(body.errors.slice()),
287
+ });
288
+ }
289
+
290
+ if (!Value.Check(TipResponseSchema, body)) {
291
+ throw invalidResponse("Midnight indexer returned an invalid tip response");
292
+ }
293
+
294
+ return { height: body.data.block.height };
295
+ }
@@ -3,6 +3,7 @@ export * from "./evm/state.ts";
3
3
 
4
4
  export * from "./midnight/fetcher.ts";
5
5
  export * from "./midnight/state.ts";
6
+ export * from "./midnight/tip.ts";
6
7
 
7
8
  export * from "./avail/fetcher.ts";
8
9
  export * from "./avail/state.ts";
@@ -12,6 +13,8 @@ export * from "./bitcoin/state.ts";
12
13
 
13
14
  export * from "./ntp/fetcher.ts";
14
15
  export * from "./ntp/state.ts";
16
+ export { getNtpTip, NtpTipError } from "./ntp/tip.ts";
17
+ export type { GetNtpTipOptions, NtpTip, NtpTipErrorCode } from "./ntp/tip.ts";
15
18
 
16
19
  export * from "./utxorpc/fetcher.ts";
17
20
  export * from "./utxorpc/state.ts";
@@ -6,7 +6,11 @@ import {
6
6
  import type { RootOutput, RootPage } from "../types.ts";
7
7
  import type { Operation } from "effection";
8
8
  import { all, call, until } from "effection";
9
- import { bound, type NtpBlockHash, type NtpPageJson } from "@effectstream/utils";
9
+ import {
10
+ bound,
11
+ type NtpBlockHash,
12
+ type NtpPageJson,
13
+ } from "@effectstream/utils";
10
14
  import { blockNumberRelation } from "../common/utils.ts";
11
15
  import type { Input, Output, Page, PrimitiveType } from "./types.ts";
12
16
  import {
@@ -24,11 +28,7 @@ import type {
24
28
  SyncProtocolWithNetwork,
25
29
  } from "@effectstream/config";
26
30
  import type { ConfigSyncProtocolType } from "@effectstream/config";
27
- import { NtpTimeSync, type NtpTimeSyncDefaultOptions } from "ntp-time-sync";
28
-
29
- type RecursivePartial<T> = {
30
- [P in keyof T]?: RecursivePartial<T[P]>;
31
- };
31
+ import { NtpTimeSync } from "ntp-time-sync";
32
32
 
33
33
  export class NtpFetcher
34
34
  extends BaseDataFetcher<Input, Output, RootOutput, Page, RootPage>
@@ -40,7 +40,8 @@ export class NtpFetcher
40
40
  PrimitiveType,
41
41
  ConfigSyncProtocolType.NTP_MAIN
42
42
  >,
43
- PaginatedFetcher<Page> {
43
+ PaginatedFetcher<Page>
44
+ {
44
45
  readonly ntpTimeSync: NtpTimeSync;
45
46
  constructor(
46
47
  readonly config: Extract<
@@ -50,17 +51,15 @@ export class NtpFetcher
50
51
  ) {
51
52
  super(config.syncProtocol.name);
52
53
 
53
- const options: RecursivePartial<typeof NtpTimeSyncDefaultOptions> = {
54
+ const options = {
54
55
  ntpDefaults: {
55
56
  minPoll: 10,
56
57
  },
58
+ ...(config.network.servers?.length
59
+ ? { servers: [...config.network.servers] }
60
+ : {}),
57
61
  };
58
- if (config.network.servers && config.network.servers.length) {
59
- options.servers = config.network.servers;
60
- }
61
- // Cannot select options
62
- // https://github.com/buffcode/ntp-time-sync/issues/104
63
- this.ntpTimeSync = NtpTimeSync.getInstance();
62
+ this.ntpTimeSync = new NtpTimeSync(options);
64
63
  }
65
64
 
66
65
  @bound
@@ -125,7 +124,7 @@ export class NtpFetcher
125
124
  own: Number(data.to),
126
125
  root: rootConversion.toRootPage({
127
126
  blockHashes: [] as NtpBlockHash[],
128
- raw: (yield* call(() => pageFetcher(Number(data.to)))),
127
+ raw: yield* call(() => pageFetcher(Number(data.to))),
129
128
  }),
130
129
  },
131
130
  };
@@ -152,15 +151,13 @@ export class NtpFetcher
152
151
  }
153
152
 
154
153
  @bound
155
- *getLatestPage(
156
- knownLastPage: undefined | Page,
157
- ): Operation<Page> {
154
+ *getLatestPage(knownLastPage: undefined | Page): Operation<Page> {
158
155
  const self = this;
159
156
  return yield* fetchNewestPage<Page>(
160
157
  blockNumberRelation,
161
158
  function* (): Operation<Page> {
162
159
  const result = yield* until(self.ntpTimeSync.getTime());
163
- if (result.offset > 5000) {
160
+ if (Math.abs(result.offset) > 5000) {
164
161
  console.error(
165
162
  `NTP offset is higher than 5[s]. Please check your NTP server & system time configuration.
166
163
  NTP time: ${result.now}