@secondlayer/subgraphs 3.20.0 → 3.21.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.
@@ -124,18 +124,45 @@ interface ContractDeployFilter {
124
124
  deployer?: string;
125
125
  contractName?: string;
126
126
  }
127
+ /**
128
+ * One declared field of a print payload.
129
+ *
130
+ * A bare {@link ColumnType} is a required scalar (`amount: "uint"`). The
131
+ * composite forms exist because real print payloads are not flat: BNS-V2
132
+ * emits `name` as a NESTED tuple, and a `prints` vocabulary that could only
133
+ * say `"jsonb"` is why a handler reading flat `data.name` type-checked,
134
+ * deployed, and then decoded to null on every event.
135
+ *
136
+ * - `{ type: F, optional: true }` — the KEY becomes optional on `event.data`
137
+ * (mirrors `always_present: false` in the inferred schema).
138
+ * - `{ tuple: { … } }` — a nested tuple, typed all the way down.
139
+ * - `{ list: F }` — a list of `F`.
140
+ */
141
+ type PrintField = ColumnType | {
142
+ type: PrintField
143
+ optional: true
144
+ } | {
145
+ tuple: Record<string, PrintField>
146
+ } | {
147
+ list: PrintField
148
+ };
127
149
  interface PrintEventFilter extends TraitScope {
128
150
  type: "print_event";
129
151
  contractId?: string;
130
152
  topic?: string;
131
153
  /**
132
- * Optional per-topic field schema. When declared, the handler's `event` is
133
- * a discriminated union keyed by `topic` and `event.data` is typed per topic
154
+ * Per-topic field schema. When declared, the handler's `event` is a
155
+ * discriminated union keyed by `topic` and `event.data` is typed per topic
134
156
  * (e.g. `{ "completed-deposit": { amount: "uint", sender: "principal" } }`).
135
- * Uses the same `ColumnType` vocab as `schema`; nested fields use `"jsonb"`.
136
- * Type-level only — not validated at runtime.
157
+ * Nested tuples, lists, and optional fields are expressible — see
158
+ * {@link PrintField}.
159
+ *
160
+ * Declaring `prints` also opts the source into RUNTIME validation: an
161
+ * event whose decoded payload does not match is skipped and logged rather
162
+ * than written as nulls (and the deploy-time field lint is promoted from
163
+ * a warning to an error).
137
164
  */
138
- prints?: Record<string, Record<string, ColumnType>>;
165
+ prints?: Record<string, Record<string, PrintField>>;
139
166
  }
140
167
  /** All subgraph filter types — discriminated on `type` */
141
168
  type SubgraphFilter = StxTransferFilter | StxMintFilter | StxBurnFilter | StxLockFilter | FtTransferFilter | FtMintFilter | FtBurnFilter | NftTransferFilter | NftMintFilter | NftBurnFilter | ContractCallFilter | ContractDeployFilter | PrintEventFilter;
@@ -758,6 +758,87 @@ import {
758
758
  deserializeCV as deserializeCV2,
759
759
  toCamelCase
760
760
  } from "@secondlayer/stacks/clarity";
761
+
762
+ // src/runtime/print-validate.ts
763
+ var COLUMN_TYPES = new Set([
764
+ "text",
765
+ "uint",
766
+ "int",
767
+ "principal",
768
+ "boolean",
769
+ "timestamp",
770
+ "jsonb"
771
+ ]);
772
+ function isColumnType(field) {
773
+ return typeof field === "string" && COLUMN_TYPES.has(field);
774
+ }
775
+ function matchesField(field, value) {
776
+ if (isColumnType(field)) {
777
+ switch (field) {
778
+ case "uint":
779
+ case "int":
780
+ return typeof value === "bigint" || typeof value === "number";
781
+ case "boolean":
782
+ return typeof value === "boolean";
783
+ case "text":
784
+ case "principal":
785
+ case "timestamp":
786
+ return typeof value === "string" || typeof value === "number" || typeof value === "bigint";
787
+ case "jsonb":
788
+ return value !== undefined;
789
+ }
790
+ }
791
+ if (typeof field !== "object" || field === null)
792
+ return false;
793
+ if ("optional" in field) {
794
+ return value === undefined || value === null || matchesField(field.type, value);
795
+ }
796
+ if ("tuple" in field) {
797
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
798
+ return false;
799
+ }
800
+ return matchesTopic(field.tuple, value).ok;
801
+ }
802
+ if ("list" in field) {
803
+ return Array.isArray(value) && value.every((item) => matchesField(field.list, item));
804
+ }
805
+ return false;
806
+ }
807
+ function matchesTopic(declared, data) {
808
+ for (const [name, field] of Object.entries(declared)) {
809
+ const present = name in data;
810
+ const optional = typeof field === "object" && field !== null && "optional" in field;
811
+ if (!present) {
812
+ if (optional)
813
+ continue;
814
+ return { ok: false, reason: `missing declared field "${name}"` };
815
+ }
816
+ if (!matchesField(field, data[name])) {
817
+ return {
818
+ ok: false,
819
+ reason: `field "${name}" does not match its declared type (got ${describe(data[name])})`
820
+ };
821
+ }
822
+ }
823
+ return { ok: true };
824
+ }
825
+ function describe(value) {
826
+ if (value === null)
827
+ return "null";
828
+ if (Array.isArray(value))
829
+ return "array";
830
+ return typeof value;
831
+ }
832
+ function validatePrintPayload(prints, topic, data) {
833
+ if (!prints)
834
+ return { ok: true };
835
+ const declared = prints[topic];
836
+ if (!declared)
837
+ return { ok: true };
838
+ return matchesTopic(declared, data);
839
+ }
840
+
841
+ // src/runtime/runner.ts
761
842
  var DEFAULT_ERROR_THRESHOLD = 50;
762
843
  function camelCase(str) {
763
844
  return str.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
@@ -1002,6 +1083,7 @@ function buildEventPayload(filter, tx, event) {
1002
1083
  async function runHandlers(subgraph, matched, ctx, opts) {
1003
1084
  let processed = 0;
1004
1085
  let errors = 0;
1086
+ let skipped = 0;
1005
1087
  const threshold = opts?.errorThreshold ?? DEFAULT_ERROR_THRESHOLD;
1006
1088
  const filterLookup = new Map;
1007
1089
  if (!Array.isArray(subgraph.sources)) {
@@ -1026,7 +1108,7 @@ async function runHandlers(subgraph, matched, ctx, opts) {
1026
1108
  errors,
1027
1109
  threshold
1028
1110
  });
1029
- return { processed, errors };
1111
+ return { processed, errors, ...skipped > 0 ? { skipped } : {} };
1030
1112
  }
1031
1113
  const handler = subgraph.handlers[sourceName] ?? subgraph.handlers["*"] ?? null;
1032
1114
  if (!handler) {
@@ -1082,6 +1164,21 @@ async function runHandlers(subgraph, matched, ctx, opts) {
1082
1164
  if (event !== null && filter?.type === "print_event" && filter.topic && payload.topic !== filter.topic) {
1083
1165
  continue;
1084
1166
  }
1167
+ if (event !== null && filter?.type === "print_event" && filter.prints) {
1168
+ const printPayload = payload;
1169
+ const verdict = validatePrintPayload(filter.prints, printPayload.topic ?? "", printPayload.data ?? {});
1170
+ if (!verdict.ok) {
1171
+ skipped++;
1172
+ logger2.warn("Print payload does not match declared prints schema", {
1173
+ subgraph: subgraph.name,
1174
+ sourceName,
1175
+ topic: printPayload.topic,
1176
+ txId: tx.tx_id,
1177
+ reason: verdict.reason
1178
+ });
1179
+ continue;
1180
+ }
1181
+ }
1085
1182
  await handler(payload, ctx);
1086
1183
  processed++;
1087
1184
  } catch (err) {
@@ -1096,7 +1193,7 @@ async function runHandlers(subgraph, matched, ctx, opts) {
1096
1193
  });
1097
1194
  }
1098
1195
  }
1099
- return { processed, errors };
1196
+ return { processed, errors, ...skipped > 0 ? { skipped } : {} };
1100
1197
  }
1101
1198
 
1102
1199
  // src/runtime/source-matcher.ts
@@ -2244,5 +2341,5 @@ export {
2244
2341
  BLOCK_RETRY_DELAYS_MS
2245
2342
  };
2246
2343
 
2247
- //# debugId=787B3926873D0C0764756E2164756E21
2344
+ //# debugId=6575DE1AB15AD38164756E2164756E21
2248
2345
  //# sourceMappingURL=block-processor.js.map