@effectstream/sync 0.200.4 → 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 +70 -4
- package/package.json +6 -6
- package/src/sync-protocols/midnight/tip.ts +295 -0
- package/src/sync-protocols/mod.ts +3 -0
- package/src/sync-protocols/ntp/fetcher.ts +16 -19
- package/src/sync-protocols/ntp/tip.ts +170 -0
- package/test/examples.test.ts +4 -0
- package/test/midnight-tip.test.ts +694 -0
- package/test/ntp-fetcher-case.ts +248 -0
- package/test/ntp-fetcher.test.ts +69 -0
- package/test/ntp-tip.test.ts +228 -0
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
17
|
-
"@effectstream/db": "0.200.
|
|
18
|
-
"@effectstream/log": "0.200.
|
|
19
|
-
"@effectstream/utils": "0.200.
|
|
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": "
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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}
|