@secondlayer/subgraphs 3.22.0 → 3.23.1

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.
Files changed (36) hide show
  1. package/dist/src/index.d.ts +118 -9
  2. package/dist/src/index.js +159 -14
  3. package/dist/src/index.js.map +7 -6
  4. package/dist/src/runtime/block-processor.d.ts +43 -2
  5. package/dist/src/runtime/block-processor.js +155 -12
  6. package/dist/src/runtime/block-processor.js.map +7 -6
  7. package/dist/src/runtime/catchup.d.ts +43 -2
  8. package/dist/src/runtime/catchup.js +155 -12
  9. package/dist/src/runtime/catchup.js.map +7 -6
  10. package/dist/src/runtime/context.d.ts +77 -3
  11. package/dist/src/runtime/context.js +142 -1
  12. package/dist/src/runtime/context.js.map +6 -5
  13. package/dist/src/runtime/processor.js +159 -16
  14. package/dist/src/runtime/processor.js.map +7 -6
  15. package/dist/src/runtime/reindex.d.ts +43 -2
  16. package/dist/src/runtime/reindex.js +155 -12
  17. package/dist/src/runtime/reindex.js.map +7 -6
  18. package/dist/src/runtime/reorg.d.ts +43 -2
  19. package/dist/src/runtime/reorg.js +155 -12
  20. package/dist/src/runtime/reorg.js.map +7 -6
  21. package/dist/src/runtime/replay.d.ts +3 -3
  22. package/dist/src/runtime/replay.js +2 -1
  23. package/dist/src/runtime/replay.js.map +3 -3
  24. package/dist/src/runtime/runner.d.ts +102 -5
  25. package/dist/src/runtime/source-matcher.d.ts +2 -2
  26. package/dist/src/runtime/stats.d.ts +3 -3
  27. package/dist/src/schema/index.d.ts +46 -5
  28. package/dist/src/service.js +159 -16
  29. package/dist/src/service.js.map +7 -6
  30. package/dist/src/testing/index.d.ts +52 -5
  31. package/dist/src/testing/index.js +172 -6
  32. package/dist/src/testing/index.js.map +6 -5
  33. package/dist/src/triggers/index.d.ts +2 -2
  34. package/dist/src/types.d.ts +43 -2
  35. package/dist/src/validate.d.ts +43 -2
  36. package/package.json +6 -4
@@ -1,3 +1,62 @@
1
+ import { UnwrapResponse } from "@secondlayer/stacks/actions";
2
+ import { AbiContract, ExtractFunctionArgs, ExtractFunctionOutput, ExtractReadOnlyFunctions, ToCamelCase } from "@secondlayer/stacks/clarity";
3
+ /**
4
+ * How long a read stays valid.
5
+ *
6
+ * - `per-block` (default): pinned to the block being processed, cached under
7
+ * its `index_block_hash`. Sound for any read-only function — reindexing the
8
+ * same block always returns the same value, and a reorg-replaced block has a
9
+ * different id so it can't inherit the orphaned answer.
10
+ * - `contract-constant`: the caller asserts the value cannot change for this
11
+ * contract and these args (SIP-010 `get-decimals`/`get-symbol`), so it is
12
+ * resolved ONCE instead of once per block. This is the mode that makes a
13
+ * backfill affordable — under `per-block` every block is a distinct key and
14
+ * therefore a distinct RPC. Declaring it on a value that does change pins the
15
+ * first answer forever, so it is opt-in and never inferred.
16
+ */
17
+ type ChainReadCacheMode = "per-block" | "contract-constant";
18
+ type ChainReadOptions = {
19
+ cache?: ChainReadCacheMode
20
+ };
21
+ /**
22
+ * The chain-read surface, fully typed by an ABI.
23
+ *
24
+ * Shaped like `getContract` from `@secondlayer/stacks` — the same ABI in, the
25
+ * same camelCased read methods out — minus `call`/`buildCall`: a handler
26
+ * indexes the chain, it never writes to it.
27
+ *
28
+ * Reached through {@link readContractAt} rather than off `ctx.client`
29
+ * directly: an ABI-generic method living on the context interface makes every
30
+ * structural comparison of a subgraph definition instantiate it, which exceeds
31
+ * TS's depth budget. At a call site the ABI is already concrete and it costs
32
+ * nothing.
33
+ */
34
+ interface ChainReadClient {
35
+ /**
36
+ * Read-only methods of a contract, evaluated against the exact block being
37
+ * processed.
38
+ *
39
+ * Every call is pinned to the block's `index_block_hash`, so a handler is a
40
+ * pure function of its block: reindexing a range produces byte-identical
41
+ * rows. Reading at the node's tip instead would make the same reindex
42
+ * return different values — that is the corruption this refuses to allow,
43
+ * so a block whose id was never persisted throws rather than falling back.
44
+ *
45
+ * Results are memoized in Postgres, so a value is fetched once per block
46
+ * rather than once per event (or once per contract with
47
+ * `cache: "contract-constant"`).
48
+ */
49
+ contract<const C extends AbiContract>(contractId: string, abi: C, options?: ChainReadOptions): {
50
+ read: ChainReadMethods<C>
51
+ };
52
+ }
53
+ /**
54
+ * The camelCased read methods of an ABI. Spelled out rather than reused from
55
+ * `ContractInstance<C>["read"]`: that type is a conditional over the codegen
56
+ * brand, and evaluating it wherever a subgraph definition is structurally
57
+ * compared pushes TS past its instantiation depth.
58
+ */
59
+ type ChainReadMethods<C extends AbiContract> = { [N in ExtractReadOnlyFunctions<C> as ToCamelCase<N>] : (args: ExtractFunctionArgs<C, N>) => Promise<UnwrapResponse<ExtractFunctionOutput<C, N>>> };
1
60
  /** Supported column types for subgraph schemas */
2
61
  type ColumnType = "text" | "uint" | "int" | "principal" | "boolean" | "timestamp" | "jsonb";
3
62
  /** Column definition in a subgraph table */
@@ -49,9 +108,9 @@ type SubgraphSchema = Record<string, SubgraphTable>;
49
108
  type RowValue = string | number | bigint | boolean | null | undefined | Record<string, unknown> | unknown[];
50
109
  /** Value or computed function that receives existing row */
51
110
  type ComputedValue = RowValue | ((existing: Record<string, unknown> | null) => unknown);
52
- import { Database } from "@secondlayer/shared/db";
53
- import { Kysely, Transaction } from "kysely";
54
- type AnyDb = Kysely<Database> | Transaction<Database>;
111
+ import { Database as Database2 } from "@secondlayer/shared/db";
112
+ import { Kysely as Kysely2, Transaction } from "kysely";
113
+ type AnyDb = Kysely2<Database2> | Transaction<Database2>;
55
114
  /** Reorg journal entries older than this many blocks are prunable — far past
56
115
  * Stacks finality (observed reorg depth is single digits). */
57
116
  declare const JOURNAL_RETENTION_BLOCKS = 300;
@@ -83,6 +142,13 @@ interface BlockMeta {
83
142
  hash: string;
84
143
  timestamp: number;
85
144
  burnBlockHeight: number;
145
+ /**
146
+ * Nakamoto StacksBlockId — the identifier `?tip=` accepts, and the only way
147
+ * to pin a read-only call to THIS block. Null on rows ingested before it
148
+ * was persisted; `ctx.client.readOnly` refuses to run rather than silently
149
+ * reading at the node's tip.
150
+ */
151
+ indexBlockHash?: string | null;
86
152
  }
87
153
  interface TxMeta2 {
88
154
  txId: string;
@@ -127,7 +193,15 @@ declare class SubgraphContext2 {
127
193
  * journal would just be churn the pruner deletes.
128
194
  */
129
195
  private readonly journal;
196
+ /** Memoized per block; see the `client` getter. */
197
+ protected _client?: ChainReadClient;
130
198
  constructor(db: AnyDb, pgSchemaName: string, subgraphSchema: SubgraphSchema, block: BlockMeta, tx: TxMeta2, byo?: boolean, journal?: boolean);
199
+ /**
200
+ * Chain reads pinned to THIS block. Lazily built so a handler that never
201
+ * reads never touches the node config, and so the in-memory test context
202
+ * can override it without a node.
203
+ */
204
+ get client(): ChainReadClient;
131
205
  get tx(): TxMeta2;
132
206
  /** Update the current transaction context (used by runner between events) */
133
207
  setTx(tx: TxMeta2): void;
@@ -145,6 +145,139 @@ function generateSubgraphSQL(def, schemaNameOverride) {
145
145
  return { statements, hash };
146
146
  }
147
147
 
148
+ // src/runtime/chain-read.ts
149
+ import { getSourceDb } from "@secondlayer/shared/db";
150
+ import { http, createPublicClient } from "@secondlayer/stacks";
151
+ import {
152
+ ContractResponseError,
153
+ buildFunctionArgs,
154
+ isResponseOutput,
155
+ readContract
156
+ } from "@secondlayer/stacks/actions";
157
+ import {
158
+ clarityValueToJSUntyped,
159
+ deserializeCV,
160
+ normalizeAbi,
161
+ serializeCV,
162
+ toCamelCase
163
+ } from "@secondlayer/stacks/clarity";
164
+ function readContractAt(ctx, contractId, abi, options) {
165
+ return ctx.client.contract(contractId, abi, options);
166
+ }
167
+
168
+ class ChainReadError extends Error {
169
+ name = "ChainReadError";
170
+ }
171
+ function nodeClient(rpcUrl) {
172
+ return createPublicClient({
173
+ transport: http(rpcUrl, { retryCount: 0, timeout: 1e4 })
174
+ });
175
+ }
176
+
177
+ class ConcurrencyGate {
178
+ limit;
179
+ active = 0;
180
+ queue = [];
181
+ constructor(limit) {
182
+ this.limit = limit;
183
+ }
184
+ async run(fn) {
185
+ if (this.active >= this.limit) {
186
+ await new Promise((resolve) => this.queue.push(resolve));
187
+ }
188
+ this.active++;
189
+ try {
190
+ return await fn();
191
+ } finally {
192
+ this.active--;
193
+ this.queue.shift()?.();
194
+ }
195
+ }
196
+ }
197
+ var DEFAULT_MAX_CONCURRENT_READS = 4;
198
+ function maxConcurrentReads() {
199
+ const raw = process.env.SUBGRAPH_CHAIN_READ_CONCURRENCY;
200
+ const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
201
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CONCURRENT_READS;
202
+ }
203
+ var gate = new ConcurrencyGate(maxConcurrentReads());
204
+ function hashArgs(args) {
205
+ const serialized = args.map((arg) => serializeCV(arg)).join(",");
206
+ return Bun.hash(serialized).toString(16);
207
+ }
208
+ function findFunction(abi, name) {
209
+ const fn = abi.functions.find((f) => f.name === name);
210
+ if (!fn) {
211
+ throw new ChainReadError(`No function "${name}" in the supplied ABI.`);
212
+ }
213
+ if (fn.access !== "read-only") {
214
+ throw new ChainReadError(`"${name}" is ${fn.access}, not read-only — a handler cannot call it.`);
215
+ }
216
+ return fn;
217
+ }
218
+ function createChainReadClient(ctx) {
219
+ const readValue = async (contractId, abi, functionName, args, options) => {
220
+ const rpcUrl = ctx.rpcUrl ?? process.env.STACKS_NODE_RPC_URL;
221
+ if (!rpcUrl) {
222
+ throw new ChainReadError("ctx.client needs a Stacks node: set STACKS_NODE_RPC_URL on the subgraph processor.");
223
+ }
224
+ const constant = options.cache === "contract-constant";
225
+ if (!constant && !ctx.indexBlockHash) {
226
+ throw new ChainReadError(`Block ${ctx.blockHeight} has no index_block_hash, so a read cannot be pinned to it. Reading at the node's tip would make this handler non-deterministic (the same reindex would produce different rows). Re-ingest the block, or pass cache: "contract-constant" if the value genuinely cannot change.`);
227
+ }
228
+ const fn = findFunction(abi, functionName);
229
+ const clarityArgs = buildFunctionArgs(fn, args);
230
+ const db = ctx.db ?? getSourceDb();
231
+ const argsHash = hashArgs(clarityArgs);
232
+ const pinnedTo = constant ? null : ctx.indexBlockHash;
233
+ const cached = await db.selectFrom("chain_read_cache").select("result_hex").where("contract_id", "=", contractId).where("function_name", "=", fn.name).where("args_hash", "=", argsHash).where((eb) => pinnedTo === null ? eb("index_block_hash", "is", null) : eb("index_block_hash", "=", pinnedTo)).executeTakeFirst();
234
+ const resultHex = cached?.result_hex ?? await gate.run(async () => {
235
+ const value2 = await readContract(nodeClient(rpcUrl), {
236
+ contract: contractId,
237
+ functionName: fn.name,
238
+ args: clarityArgs,
239
+ tip: ctx.indexBlockHash ?? undefined
240
+ });
241
+ return serializeCV(value2);
242
+ });
243
+ if (!cached) {
244
+ await db.insertInto("chain_read_cache").values({
245
+ contract_id: contractId,
246
+ function_name: fn.name,
247
+ args_hash: argsHash,
248
+ index_block_hash: pinnedTo,
249
+ block_height: constant ? null : ctx.blockHeight,
250
+ result_hex: resultHex
251
+ }).onConflict((oc) => oc.doNothing()).execute();
252
+ }
253
+ const value = deserializeCV(resultHex);
254
+ const js = clarityValueToJSUntyped(fn.outputs, value);
255
+ if (isResponseOutput(fn.outputs)) {
256
+ const response = js;
257
+ if ("ok" in response)
258
+ return response.ok;
259
+ throw new ContractResponseError(`${contractId}::${fn.name} returned an err response.`, response.err);
260
+ }
261
+ return js;
262
+ };
263
+ return {
264
+ contract(contractId, rawAbi, options = {}) {
265
+ const abi = normalizeAbi(rawAbi);
266
+ const camelToKebab = new Map;
267
+ for (const fn of abi.functions) {
268
+ camelToKebab.set(toCamelCase(fn.name), fn.name);
269
+ }
270
+ const read = new Proxy({}, {
271
+ get(_target, prop) {
272
+ const fnName = camelToKebab.get(prop) ?? prop;
273
+ return (args = {}) => readValue(contractId, abi, fnName, args, options);
274
+ }
275
+ });
276
+ return { read };
277
+ }
278
+ };
279
+ }
280
+
148
281
  // src/runtime/context.ts
149
282
  var JOURNAL_RETENTION_BLOCKS = 300;
150
283
  var journalEnsured = new Set;
@@ -163,6 +296,7 @@ class SubgraphContext {
163
296
  ops = [];
164
297
  byo;
165
298
  journal;
299
+ _client;
166
300
  constructor(db, pgSchemaName2, subgraphSchema, block, tx, byo = false, journal = false) {
167
301
  this.db = db;
168
302
  this.pgSchemaName = pgSchemaName2;
@@ -172,6 +306,13 @@ class SubgraphContext {
172
306
  this.byo = byo;
173
307
  this.journal = journal;
174
308
  }
309
+ get client() {
310
+ this._client ??= createChainReadClient({
311
+ blockHeight: this.block.height,
312
+ indexBlockHash: this.block.indexBlockHash
313
+ });
314
+ return this._client;
315
+ }
175
316
  get tx() {
176
317
  return this._tx;
177
318
  }
@@ -736,5 +877,5 @@ export {
736
877
  JOURNAL_RETENTION_BLOCKS
737
878
  };
738
879
 
739
- //# debugId=52F456C4AF4B631864756E2164756E21
880
+ //# debugId=943E34F234EEBFC564756E2164756E21
740
881
  //# sourceMappingURL=context.js.map