@secondlayer/sdk 9.0.2 → 10.0.0
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/AGENTS.md +78 -17
- package/README.md +98 -31
- package/dist/index.d.ts +230 -111
- package/dist/index.js +768 -436
- package/dist/index.js.map +19 -19
- package/dist/sinks/bun-sqlite.d.ts +4 -1
- package/dist/sinks/bun-sqlite.js +18 -19
- package/dist/sinks/bun-sqlite.js.map +5 -5
- package/dist/sinks/core.d.ts +8 -1
- package/dist/sinks/core.js +15 -19
- package/dist/sinks/core.js.map +4 -4
- package/dist/sinks/drizzle.d.ts +4 -1
- package/dist/sinks/drizzle.js +19 -20
- package/dist/sinks/drizzle.js.map +5 -5
- package/dist/sinks/kysely.d.ts +4 -1
- package/dist/sinks/kysely.js +20 -20
- package/dist/sinks/kysely.js.map +5 -5
- package/dist/sinks/testing.d.ts +4 -1
- package/dist/sinks/testing.js +14 -1
- package/dist/sinks/testing.js.map +3 -3
- package/dist/streams/index.d.ts +90 -22
- package/dist/streams/index.js +395 -183
- package/dist/streams/index.js.map +12 -12
- package/dist/subgraphs/index.d.ts +198 -92
- package/dist/subgraphs/index.js +764 -434
- package/dist/subgraphs/index.js.map +17 -17
- package/package.json +4 -4
package/AGENTS.md
CHANGED
|
@@ -4,20 +4,24 @@ Five facts that are load-bearing. Everything else you can infer from the types;
|
|
|
4
4
|
these you cannot, and getting them wrong produces code that looks correct and
|
|
5
5
|
silently loses or corrupts data.
|
|
6
6
|
|
|
7
|
-
## 1.
|
|
7
|
+
## 1. `/v1` reads are open on loopback; `/api` needs the token
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
no credentials and start reading:
|
|
9
|
+
`sl.index.*`, `sl.streams.*`, `sl.subgraphs.rows`, and the typed `subscribe`
|
|
10
|
+
read `/v1`, open on loopback. Construct the client with no credentials and start reading:
|
|
11
11
|
|
|
12
12
|
```ts
|
|
13
|
-
import {
|
|
13
|
+
import { SecondLayer } from "@secondlayer/sdk";
|
|
14
14
|
|
|
15
|
-
const sl = new
|
|
16
|
-
const { events } = await sl.events.list({ eventType: "ft_transfer" });
|
|
15
|
+
const sl = new SecondLayer();
|
|
16
|
+
const { events } = await sl.index.events.list({ eventType: "ft_transfer" });
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
Everything else under `sl.subgraphs.*` and all of `sl.subscriptions.*` calls
|
|
20
|
+
`/api`, which needs `INSTANCE_TOKEN` as soon as one is configured, loopback
|
|
21
|
+
included, and `secondlayer init` always configures one. `/v1` needs it too once
|
|
22
|
+
the API is bound beyond loopback. Every client reads `INSTANCE_TOKEN` from the
|
|
23
|
+
env when `apiKey` is omitted, so the golden path is: omit `apiKey`, export the
|
|
24
|
+
token. Do not send a placeholder; an empty Bearer is worse than none.
|
|
21
25
|
|
|
22
26
|
## 2. Cursors are opaque
|
|
23
27
|
|
|
@@ -28,7 +32,7 @@ arithmetically. Pass back exactly what you were handed:
|
|
|
28
32
|
```ts
|
|
29
33
|
let cursor: string | null = null;
|
|
30
34
|
for (;;) {
|
|
31
|
-
const page = await sl.events.list({ eventType: "ft_transfer", cursor });
|
|
35
|
+
const page = await sl.index.events.list({ eventType: "ft_transfer", cursor });
|
|
32
36
|
if (page.events.length === 0) break;
|
|
33
37
|
cursor = page.next_cursor; // ✓
|
|
34
38
|
// cursor = `${lastHeight}:0`; // ✗ — skips events, silently
|
|
@@ -49,10 +53,30 @@ consistent.
|
|
|
49
53
|
```ts
|
|
50
54
|
onReorg: async (reorg, ctx) => {
|
|
51
55
|
await tx.deleteFrom("my_rows").where("height", ">=", reorg.fork_point_height).execute();
|
|
52
|
-
await saveCursor(tx, ctx.cursor);
|
|
56
|
+
await saveCursor(tx, ctx.cursor); // string, or null for a fork at genesis
|
|
53
57
|
},
|
|
54
58
|
```
|
|
55
59
|
|
|
60
|
+
Three more reorg facts the loop enforces so you do not have to:
|
|
61
|
+
|
|
62
|
+
- **Never rewinds forward.** A page read from below a fork still reports that
|
|
63
|
+
fork. Nothing past it has been written, so the loop skips the rollback and
|
|
64
|
+
delivers the page; `onReorg` fires only for forks at or below the checkpoint.
|
|
65
|
+
- **Idle tip is covered (Streams).** A page only reports reorgs overlapping its
|
|
66
|
+
own span, so on every empty page the Streams loop also calls
|
|
67
|
+
`reorgs.list` (one extra request per idle poll) and rolls back anything it
|
|
68
|
+
finds. Index has no reorg list; a fork while idling is seen once a page
|
|
69
|
+
overlaps it.
|
|
70
|
+
- **Rollback depth is capped.** The fork point is server supplied and drives a
|
|
71
|
+
`DELETE ... >= fork` on every declared table, so a rewind more than
|
|
72
|
+
`maxRollbackDepth` blocks (default 1000) below the checkpoint throws a
|
|
73
|
+
`ValidationError` before the sink runs. Raise it only for a source you trust.
|
|
74
|
+
- **Rollback is at-least-once across restarts.** Applied reorgs are deduped in
|
|
75
|
+
memory only. A consumer restarted at a checkpoint below a recent fork's
|
|
76
|
+
orphaned tip sees that fork again, fires `onReorg` again, and re-reads.
|
|
77
|
+
Correct (the rollback is idempotent, the re-read is a no-op on keyed rows)
|
|
78
|
+
but not free: persist a checkpoint above the fork to stop it.
|
|
79
|
+
|
|
56
80
|
## 4. Rows and the cursor commit in ONE transaction
|
|
57
81
|
|
|
58
82
|
If they commit separately, a crash between them either replays a batch (double
|
|
@@ -75,12 +99,34 @@ await sl.streams.events.consume({
|
|
|
75
99
|
Hand-rolling it is allowed, but the checkpoint write must be inside the same
|
|
76
100
|
transaction as the row writes.
|
|
77
101
|
|
|
78
|
-
## 5. `walk()`
|
|
102
|
+
## 5. `walk()` and `events.subscribe()` are not reorg-safe
|
|
79
103
|
|
|
80
104
|
`walk()` iterates a fixed range for backfills and analysis. It does not surface
|
|
81
|
-
reorgs and does not rewind.
|
|
82
|
-
|
|
83
|
-
|
|
105
|
+
reorgs and does not rewind. `streams.events.subscribe()` pushes events over
|
|
106
|
+
SSE as they land and never retracts one that a reorg orphans. Anything writing
|
|
107
|
+
durable state off the tip belongs in `consume()` with `onReorg` (or a sink).
|
|
108
|
+
Use `walk()` for history that is already final and `subscribe()` for displays
|
|
109
|
+
and notifications. `subscribe()` is at-least-once: the cursor advances after
|
|
110
|
+
`onEvent` resolves, so a throwing handler sees the event again on reconnect;
|
|
111
|
+
401/4xx/signature errors end the loop and reject `subscription.done`, transport
|
|
112
|
+
errors reconnect with backoff. `batchSize` tops out at 1000 (the largest page the Index
|
|
113
|
+
serves); above that `walk()` throws `ValidationError` instead of paging in
|
|
114
|
+
silently smaller steps. A walk ends only when the server stops advancing
|
|
115
|
+
`next_cursor`, never on a short page.
|
|
116
|
+
|
|
117
|
+
## 6. `replay()` dump delivery is file-granular, at-least-once
|
|
118
|
+
|
|
119
|
+
`events.replay({ from })` hands `onDumpFile` every dump file ending above
|
|
120
|
+
`from`, whole. The file that straddles `from` includes rows at or below it, so
|
|
121
|
+
skip those with `ctx.from` (the same cursor) or key rows by `cursor` so a
|
|
122
|
+
re-run is a no-op. Files ending at or below `from` are not delivered.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
onDumpFile: async (file, { from }) => {
|
|
126
|
+
const rows = await readParquet(await streams.dumps.download(file));
|
|
127
|
+
await insert(rows.filter((r) => from === null || isAfter(r.cursor, from)));
|
|
128
|
+
},
|
|
129
|
+
```
|
|
84
130
|
|
|
85
131
|
---
|
|
86
132
|
|
|
@@ -96,9 +142,24 @@ already final.
|
|
|
96
142
|
the SELECT server-side; unrequested columns are absent from the row *and* the
|
|
97
143
|
type. `cursor`, `block_height`, and `event_type` always come back.
|
|
98
144
|
- **Errors**: everything derives from `SecondLayerError` — `code`, `retryable`,
|
|
99
|
-
`retryAfterSeconds`, `docsUrl`, and `walk(predicate)` to find a cause.
|
|
100
|
-
|
|
101
|
-
|
|
145
|
+
`retryAfterSeconds`, `docsUrl`, and `walk(predicate)` to find a cause. Every
|
|
146
|
+
failure status keeps the server's `{error, code}` envelope, Streams and Index
|
|
147
|
+
alike; a 409 `OPERATION_IN_PROGRESS` on deploy/reindex/backfill is a plain
|
|
148
|
+
`ApiError`. `context()` never throws: each field is `{ value, error? }`.
|
|
149
|
+
- **Verification**: `verify: true` fetches the signing key once and caches
|
|
150
|
+
only a successful fetch (a 5xx there is a retryable `StreamsServerError`).
|
|
151
|
+
Over plain http off loopback it requires `verify: { publicKey }`.
|
|
152
|
+
- **Retries**: `consume()` and `walk()` retry each page fetch on 429/5xx/network
|
|
153
|
+
(`retryCount` default 3, `retryDelay` default 1000 ms, `Retry-After` honored);
|
|
154
|
+
`onError` is a void observer, not a retry decision. One-shot reads (`list`,
|
|
155
|
+
`get`, `discover`) do not retry; wrap them yourself if a blip must not fail
|
|
156
|
+
the call. Index and REST requests (`Index`, `Contracts`, `Subgraphs`) time
|
|
157
|
+
out after `requestTimeoutMs` (default 30 s, `0` disables) as a retryable
|
|
158
|
+
`ApiError` with code `REQUEST_TIMEOUT`, so a hung socket trips the retry
|
|
159
|
+
policy instead of stalling a loop. Streams requests do not time out yet.
|
|
160
|
+
`signal` on `walk()` cancels the in-flight request and the walk rejects
|
|
161
|
+
with the signal's reason at every boundary, so a walk that returns without
|
|
162
|
+
throwing reached the end of the feed.
|
|
102
163
|
|
|
103
164
|
## More
|
|
104
165
|
|
package/README.md
CHANGED
|
@@ -14,14 +14,19 @@ bun add @secondlayer/sdk
|
|
|
14
14
|
import { SecondLayer } from "@secondlayer/sdk";
|
|
15
15
|
|
|
16
16
|
const sl = new SecondLayer({
|
|
17
|
-
apiKey: process.env.INSTANCE_TOKEN, // from secondlayer init;
|
|
17
|
+
apiKey: process.env.INSTANCE_TOKEN, // from secondlayer init; read from the env when omitted
|
|
18
18
|
// default baseUrl: http://127.0.0.1:3800 (or SL_API_URL)
|
|
19
19
|
});
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
Auth, once: `sl.index`, `sl.streams`, `sl.subgraphs.rows`, and the typed
|
|
23
|
+
`subscribe` read `/v1`, which is open on loopback and needs `INSTANCE_TOKEN`
|
|
24
|
+
once the API is bound beyond it. Everything under `sl.subgraphs.*` and `sl.subscriptions.*` calls
|
|
25
|
+
`/api`, which needs `INSTANCE_TOKEN` as soon as one is configured, loopback
|
|
26
|
+
included, and `secondlayer init` always configures one. Every client, including
|
|
27
|
+
`createStreamsClient`, reads `INSTANCE_TOKEN` from the env when `apiKey` is
|
|
28
|
+
omitted; pass `apiKey: ""` to force a keyless call. Public archive dumps
|
|
29
|
+
(`sl.streams.dumps`) need no instance key.
|
|
25
30
|
|
|
26
31
|
## Mental model
|
|
27
32
|
|
|
@@ -39,7 +44,7 @@ Everything is indexing — the question is how much of the indexer you run:
|
|
|
39
44
|
|
|
40
45
|
## Streams
|
|
41
46
|
|
|
42
|
-
Typed HTTP client for the raw event firehose.
|
|
47
|
+
Typed HTTP client for the raw event firehose. `/v1` reads need no key on loopback.
|
|
43
48
|
|
|
44
49
|
```typescript
|
|
45
50
|
const tip = await sl.streams.tip();
|
|
@@ -62,7 +67,7 @@ console.log({ tip, firstCursor: page.events[0]?.cursor });
|
|
|
62
67
|
import { createStreamsClient } from "@secondlayer/sdk";
|
|
63
68
|
|
|
64
69
|
const streams = createStreamsClient({
|
|
65
|
-
// apiKey: process.env.INSTANCE_TOKEN, //
|
|
70
|
+
// apiKey: process.env.INSTANCE_TOKEN, // read from the env when omitted
|
|
66
71
|
// verify: true, // verify ed25519 X-Signature on every read
|
|
67
72
|
// // (auto-fetches the public key; { publicKey } pins a PEM)
|
|
68
73
|
// dumpsBaseUrl: process.env.SL_STREAMS_DUMPS_URL, // required to use client.dumps
|
|
@@ -73,7 +78,16 @@ Verified responses: every Streams read is signed (ed25519 `X-Signature` +
|
|
|
73
78
|
`X-Signature-KeyId`). Pass `verify: true` to check it on every read (or
|
|
74
79
|
`{ publicKey }` to pin a PEM); a missing/bad signature throws
|
|
75
80
|
`StreamsSignatureError`. The public key is at
|
|
76
|
-
`GET /public/streams/signing-key`.
|
|
81
|
+
`GET /public/streams/signing-key`. `verify` and `verifyDumpsManifest` are
|
|
82
|
+
accepted by `new SecondLayer({...})` too and reach `sl.streams`.
|
|
83
|
+
|
|
84
|
+
What `verify: true` proves depends on where the key came from. Over https, or
|
|
85
|
+
on loopback, the key is fetched once from the instance and cached; a 5xx on
|
|
86
|
+
that fetch surfaces as a retryable `StreamsServerError` and the next read
|
|
87
|
+
fetches again. Over plain http to any other host the key travels the same
|
|
88
|
+
unprotected path as the data, so the client refuses `verify: true` there and
|
|
89
|
+
asks for `verify: { publicKey }` instead. The tradeoff: you copy the PEM once,
|
|
90
|
+
and a server-side rotation then fails closed until you update it.
|
|
77
91
|
|
|
78
92
|
Convenience reads:
|
|
79
93
|
|
|
@@ -125,28 +139,39 @@ for await (const event of streams.events.stream({
|
|
|
125
139
|
|
|
126
140
|
Real-time push (`events.subscribe`).
|
|
127
141
|
|
|
128
|
-
Use `client.events.subscribe` for callback-style live delivery
|
|
142
|
+
Use `client.events.subscribe` for callback-style live delivery: it pushes each
|
|
129
143
|
event to `onEvent` as it lands. It's fetch-based (so it carries the Bearer key)
|
|
130
|
-
and works in browsers and Node 18+. It
|
|
131
|
-
|
|
144
|
+
and works in browsers and Node 18+. It reconnects from the last handled cursor
|
|
145
|
+
after a dropped socket, a clean server close, or `staleAfterMs` (60 s) with no
|
|
146
|
+
frame, backing off from `reconnectDelayMs` (1 s) up to 30 s with jitter and
|
|
147
|
+
honoring `Retry-After`. Errors a retry cannot fix (401, other 4xx, a bad
|
|
148
|
+
signature) end the loop: `onError` sees them and the handle's `done` rejects.
|
|
132
149
|
|
|
133
150
|
```typescript
|
|
134
|
-
const
|
|
151
|
+
const subscription = streams.events.subscribe({
|
|
135
152
|
types: ["ft_transfer"], // notTypes / contractId / sender / recipient / assetIdentifier also filter
|
|
136
153
|
// fromCursor: lastCursor, // resume strictly after this cursor; omit to tail from the tip
|
|
137
154
|
onEvent: async (event) => {
|
|
138
155
|
console.log(event.cursor, event.tx_id);
|
|
139
156
|
},
|
|
140
|
-
onError: (err) => console.error("
|
|
157
|
+
onError: (err) => console.error("subscribe", err),
|
|
141
158
|
});
|
|
142
159
|
|
|
160
|
+
await subscription.done; // rejects when the loop stopped on an unfixable error
|
|
143
161
|
// later
|
|
144
|
-
|
|
162
|
+
subscription(); // unsubscribe, or pass `signal` and abort it
|
|
145
163
|
```
|
|
146
164
|
|
|
165
|
+
The cursor advances only after `onEvent` resolves, so a handler that throws
|
|
166
|
+
sees the same event again on reconnect (at-least-once). Key durable writes by
|
|
167
|
+
`cursor`. `subscribe` is not reorg-aware: an event delivered then orphaned is
|
|
168
|
+
never retracted, so anything writing durable state belongs in `consume()` with
|
|
169
|
+
`onReorg` or a sink.
|
|
170
|
+
|
|
147
171
|
Each pushed frame is `{ event, sig, key_id }`. When the client was created with
|
|
148
172
|
`verify` (or `{ publicKey }`), the per-frame ed25519 signature is checked before
|
|
149
|
-
`onEvent` runs; a bad/missing signature
|
|
173
|
+
`onEvent` runs; a bad/missing signature ends the subscription with
|
|
174
|
+
`StreamsSignatureError`.
|
|
150
175
|
|
|
151
176
|
Bulk parquet dumps.
|
|
152
177
|
|
|
@@ -163,7 +188,6 @@ with `verifyDumpsManifest: false`.
|
|
|
163
188
|
|
|
164
189
|
```typescript
|
|
165
190
|
const streams = createStreamsClient({
|
|
166
|
-
apiKey: process.env.SL_API_KEY!,
|
|
167
191
|
dumpsBaseUrl: process.env.SL_STREAMS_DUMPS_URL!,
|
|
168
192
|
});
|
|
169
193
|
|
|
@@ -174,18 +198,34 @@ for (const file of manifest.files) {
|
|
|
174
198
|
}
|
|
175
199
|
```
|
|
176
200
|
|
|
201
|
+
`download` hashes the body as it streams in and retries a dropped socket or
|
|
202
|
+
5xx with the same policy `consume()` uses for pages, so a multi-hundred-MB
|
|
203
|
+
file costs its own size in memory, not double, and one flaky fetch does not
|
|
204
|
+
end a replay. The bytes are still returned whole; write them out per file.
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
for (const file of (await streams.dumps.list()).files) {
|
|
208
|
+
await Bun.write(file.path.split("/").pop()!, await streams.dumps.download(file));
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
177
212
|
Backfill then tail (`events.replay`).
|
|
178
213
|
|
|
179
214
|
Backfills from bulk dumps, then tails live from the manifest's
|
|
180
|
-
`latest_finalized_cursor
|
|
215
|
+
`latest_finalized_cursor`, with no gap or dupe at the seam. `onDumpFile` hands you
|
|
181
216
|
each finalized file; `onBatch` receives live events after the seam.
|
|
182
217
|
|
|
218
|
+
Dump delivery is file-granular and at-least-once: the file straddling `from`
|
|
219
|
+
arrives whole, with `ctx.from` so you can skip rows at or below the checkpoint
|
|
220
|
+
(or key rows by `cursor` and let the upsert dedupe). Files ending at or below
|
|
221
|
+
`from` are not delivered.
|
|
222
|
+
|
|
183
223
|
```typescript
|
|
184
224
|
await streams.events.replay({
|
|
185
225
|
from: lastCheckpoint,
|
|
186
|
-
async onDumpFile(file) {
|
|
226
|
+
async onDumpFile(file, { from }) {
|
|
187
227
|
const bytes = await streams.dumps.download(file);
|
|
188
|
-
await ingestParquet(bytes); // your tooling
|
|
228
|
+
await ingestParquet(bytes, { skipAtOrBelow: from }); // your tooling
|
|
189
229
|
},
|
|
190
230
|
async onBatch(events, envelope) {
|
|
191
231
|
for (const event of events) await handle(event);
|
|
@@ -245,9 +285,12 @@ Checkpointed consumer — build your app index.
|
|
|
245
285
|
|
|
246
286
|
`index.events.consume` / `index.contractCalls.consume` is the same contract as
|
|
247
287
|
the Streams consumer: write your rows inside `onBatch`, return the cursor you
|
|
248
|
-
committed, and reorgs rewind automatically to the fork point.
|
|
249
|
-
|
|
250
|
-
|
|
288
|
+
committed, and reorgs rewind automatically to the fork point. Only forks at or
|
|
289
|
+
below your checkpoint roll back (a fork above it has nothing to undo), and a
|
|
290
|
+
rewind deeper than `maxRollbackDepth` (default 1000 blocks) is refused before
|
|
291
|
+
anything is deleted. `finalizedOnly` holds delivery to rows at or below
|
|
292
|
+
`tip.finalized_height` (Index rows carry no per-event flag). Walkthrough:
|
|
293
|
+
[docs/index](https://www.secondlayer.tools/docs/index#build-your-index-on-it).
|
|
251
294
|
|
|
252
295
|
```typescript
|
|
253
296
|
await sl.index.contractCalls.consume({
|
|
@@ -338,7 +381,7 @@ Exported types: `TransactionProof`, `TransactionProofVerifyResult`, `RewardSet`.
|
|
|
338
381
|
|
|
339
382
|
Deploy and query app-specific tables.
|
|
340
383
|
|
|
341
|
-
Subgraphs and subscriptions live on the
|
|
384
|
+
Subgraphs and subscriptions live on the instance API alongside Streams and Index. Everything here except `rows` and the typed `subscribe` calls `/api`, which needs `INSTANCE_TOKEN` once one is configured (init always configures one), loopback included.
|
|
342
385
|
|
|
343
386
|
```typescript
|
|
344
387
|
// List
|
|
@@ -347,7 +390,7 @@ const { data } = await sl.subgraphs.list();
|
|
|
347
390
|
// Get
|
|
348
391
|
const subgraph = await sl.subgraphs.status("my-subgraph");
|
|
349
392
|
|
|
350
|
-
// Open read (/v1)
|
|
393
|
+
// Open read (/v1): keyless on loopback; needs the token once the API is bound beyond it
|
|
351
394
|
const { rows, next_cursor, tip } = await sl.subgraphs.rows("my-subgraph", "transfers", {
|
|
352
395
|
order: "desc",
|
|
353
396
|
limit: 50,
|
|
@@ -374,6 +417,12 @@ const gaps = await sl.subgraphs.gaps("my-subgraph");
|
|
|
374
417
|
const result = await sl.subgraphs.deploy({ name, sources, schema, handlerCode });
|
|
375
418
|
```
|
|
376
419
|
|
|
420
|
+
Filters and `orderBy` on the typed client name the system columns
|
|
421
|
+
`_id`, `_blockHeight`, `_txId`, `_createdAt` (the canonical row shape). The
|
|
422
|
+
unprefixed `id` / `blockHeight` / `txId` / `createdAt` shorthands mean the
|
|
423
|
+
system column only when your table declares no column of that name; a declared
|
|
424
|
+
`id` column is always your `id`.
|
|
425
|
+
|
|
377
426
|
Stream rows live with the typed client — each table exposes `subscribe`
|
|
378
427
|
alongside `findMany`/`count`:
|
|
379
428
|
|
|
@@ -393,10 +442,14 @@ const unsubscribe = subgraph.transfers.subscribe(
|
|
|
393
442
|
unsubscribe();
|
|
394
443
|
```
|
|
395
444
|
|
|
396
|
-
`subscribe`
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
445
|
+
`subscribe` reads `/v1` like `rows`, so it is keyless on loopback; it is a
|
|
446
|
+
fetch-based SSE stream, so it carries the client's bearer token once the API is
|
|
447
|
+
bound past loopback, in browsers and Node 18+. Frames are unsigned rows in the
|
|
448
|
+
wire shape (`_block_height`, bigint columns as strings). `since: <block_height>`
|
|
449
|
+
replays matching rows from that height, then tails the live edge; omit it to
|
|
450
|
+
tail only. A dropped connection reconnects from the last delivered row's
|
|
451
|
+
`_block_height`, so rows at that height can arrive twice: key durable writes
|
|
452
|
+
by `_id`.
|
|
400
453
|
|
|
401
454
|
## Subscriptions
|
|
402
455
|
|
|
@@ -435,7 +488,7 @@ to a SIP/trait (e.g. `"sip-010"`); amounts are non-negative integer strings
|
|
|
435
488
|
```typescript
|
|
436
489
|
import { SecondLayer, trigger } from "@secondlayer/sdk";
|
|
437
490
|
|
|
438
|
-
const sl = new SecondLayer({ apiKey:
|
|
491
|
+
const sl = new SecondLayer({ apiKey: process.env.INSTANCE_TOKEN });
|
|
439
492
|
|
|
440
493
|
const { subscription, signingSecret } = await sl.subscriptions.create({
|
|
441
494
|
name: "amm-swaps",
|
|
@@ -572,7 +625,21 @@ try {
|
|
|
572
625
|
}
|
|
573
626
|
```
|
|
574
627
|
|
|
575
|
-
|
|
628
|
+
Every failure status keeps the server's envelope: a 401 with
|
|
629
|
+
`code: "TOKEN_REVOKED"` reads differently from a missing token, a 503 carries
|
|
630
|
+
its reason and `retryAfterSeconds`, and Streams 4xx errors carry `code` the
|
|
631
|
+
same way Index errors do (`err.code === "CURSOR_INVALID"`).
|
|
632
|
+
|
|
633
|
+
Codes you will branch on:
|
|
634
|
+
|
|
635
|
+
- `code: "OPERATION_IN_PROGRESS"` (409) from `subgraphs.deploy`, `reindex`, or
|
|
636
|
+
`backfill`: a reindex or backfill is already running for that subgraph. Poll
|
|
637
|
+
`subgraphs.operations(name)` and retry when it finishes. There is no
|
|
638
|
+
dedicated error class for this; it is an `ApiError` with `status === 409`.
|
|
639
|
+
- `code: "REQUEST_TIMEOUT"` (status 0): the request outlived `requestTimeoutMs`.
|
|
640
|
+
Retryable.
|
|
576
641
|
|
|
577
|
-
|
|
578
|
-
|
|
642
|
+
`sl.context()` never throws. Each field is `{ value, error? }`, so a `null`
|
|
643
|
+
says why: `error.status === 0` means the API was unreachable,
|
|
644
|
+
`error.code === "UNAUTHORIZED"` means the token was rejected, and no `error`
|
|
645
|
+
at all means the read succeeded and found nothing.
|