@secondlayer/sdk 9.0.1 → 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 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. Reads need no key
7
+ ## 1. `/v1` reads are open on loopback; `/api` needs the token
8
8
 
9
- `/v1/index/*` and the public subgraph reads are open. Construct the client with
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 { Index } from "@secondlayer/sdk";
13
+ import { SecondLayer } from "@secondlayer/sdk";
14
14
 
15
- const sl = new Index();
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
- Keys are for Streams, writes, and higher rate limits. Do not invent an
20
- `apiKey` requirement or send a placeholder — an empty Bearer is worse than none.
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()` is not reorg-safe
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. Anything writing durable state off the tip belongs
82
- in `consume()` with `onReorg` (or a sink). Use `walk()` for history that is
83
- already final.
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. Retries
100
- are built in (`retryCount`/`retryDelay`); `onError` is a void observer, not a
101
- retry decision.
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; omit on loopback
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
- Reads hit your instance. Writes use `INSTANCE_TOKEN` from `secondlayer init` when the
23
- API is bound beyond loopback. Public archive dumps (`client.dumps`) need no
24
- instance key.
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. Loopback reads need no key.
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, // when the API is bound beyond loopback
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 — it pushes each
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 auto-reconnects from the last delivered
131
- cursor on a dropped connection, and returns an unsubscribe function.
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 unsubscribe = streams.events.subscribe({
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("reconnecting…", err),
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
- unsubscribe(); // or pass `signal` and abort it
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 throws `StreamsSignatureError`.
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` — no gap or dupe at the seam. `onDumpFile` hands you
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. `finalizedOnly`
249
- holds delivery to rows at or below `tip.finalized_height` (Index rows carry no
250
- per-event flag). Full runnable example: `examples/sales-index/`.
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 platform API alongside Streams and Index. Deploying and managing them needs your `sk-sl_` key — no extra setup, no tenant URL.
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) — keyless for public subgraphs; pass apiKey for your private ones
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` is an SSE stream over the global `EventSource` (available in
397
- browsers and Node ≥ 22; it throws if no `EventSource` is present). Frames are
398
- unsigned rows. `since: <block_height>` replays matching rows from that height,
399
- then tails the live edge; omit it to tail only.
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: "sk-sl_..." });
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
- Tenant-resolution failures surface as `ApiError` with distinctive codes:
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
- - `code: "TENANT_SUSPENDED"` — your tenant is suspended (see `err.message` for the limit reason)
578
- - `code: "NO_TENANT"` — your account has no provisioned tenant yet
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.