@secondlayer/shared 7.3.1 → 7.5.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.
@@ -682,6 +682,21 @@ interface ChainReadCacheTable {
682
682
  result_hex: string;
683
683
  created_at: Generated<Date>;
684
684
  }
685
+ /**
686
+ * A block that arrived for a height we already hold, staged until a later
687
+ * block's `parent_hash` says which of the two the chain kept. See migration
688
+ * 0112 — adopting on first sight picked the losing fork twice.
689
+ */
690
+ interface PendingForkBlocksTable {
691
+ height: number;
692
+ block_hash: string;
693
+ parent_hash: string;
694
+ /** The canonical hash we held at this height when the contender arrived. */
695
+ incumbent_hash: string;
696
+ /** The raw observer payload, replayed verbatim if this fork wins. */
697
+ payload: ColumnType<unknown, unknown, unknown>;
698
+ received_at: Generated<Date>;
699
+ }
685
700
  interface Database {
686
701
  blocks: BlocksTable;
687
702
  transactions: TransactionsTable;
@@ -744,6 +759,7 @@ interface Database {
744
759
  x402_payments: X402PaymentsTable;
745
760
  x402_balances: X402BalancesTable;
746
761
  chain_read_cache: ChainReadCacheTable;
762
+ pending_fork_blocks: PendingForkBlocksTable;
747
763
  }
748
764
  /** Prepaid x402 credit — one running USD-micros balance per payer principal. */
749
765
  interface X402BalancesTable {
@@ -986,4 +1002,29 @@ declare function checkChainDataIntegrity(db: Kysely<Database>, opts?: {
986
1002
  lookback?: number
987
1003
  }): Promise<ChainDataIntegrity>;
988
1004
  declare function computeContiguousTip(db: Kysely<Database>, fromHeight: number): Promise<number>;
989
- export { findGaps, countMissingBlocks, computeContiguousTip, checkChainDataIntegrity, Gap, ChainDataIntegrity };
1005
+ type BrokenLink = {
1006
+ height: number
1007
+ hash: string
1008
+ expectedParent: string
1009
+ storedParent: string
1010
+ };
1011
+ /**
1012
+ * Canonical heights whose block does not descend from the canonical block
1013
+ * below it.
1014
+ *
1015
+ * `findGaps` asks "is every height present?", which a chain sitting on a losing
1016
+ * fork answers yes to. On 2026-07-30 a reorg at 8,663,166 was resolved the
1017
+ * wrong way: we adopted a block that mainnet orphaned, its true successors were
1018
+ * swept non-canonical, and later blocks were inserted canonical on top. Every
1019
+ * height existed. Nothing was "missing". The chain simply did not join up, and
1020
+ * no check asked that question — so we served a broken chain for seventeen
1021
+ * hours and only noticed because subgraphs stopped moving.
1022
+ *
1023
+ * Scoped to a recent window by default: an unlinked height is an active-repair
1024
+ * concern, and scanning eight million rows every cycle is not.
1025
+ */
1026
+ declare function findBrokenLinks(db: Kysely<Database>, opts?: {
1027
+ window?: number
1028
+ limit?: number
1029
+ }): Promise<BrokenLink[]>;
1030
+ export { findGaps, findBrokenLinks, countMissingBlocks, computeContiguousTip, checkChainDataIntegrity, Gap, ChainDataIntegrity, BrokenLink };
@@ -92,12 +92,37 @@ async function computeContiguousTip(db, fromHeight) {
92
92
  `.execute(db);
93
93
  return Number(rows[0]?.tip ?? fromHeight);
94
94
  }
95
+ async function findBrokenLinks(db, opts = {}) {
96
+ const windowSize = opts.window ?? 1e4;
97
+ const limitClause = opts.limit ? sql`LIMIT ${opts.limit}` : sql``;
98
+ const { rows } = await sql`
99
+ WITH recent AS (
100
+ SELECT height, hash, parent_hash
101
+ FROM blocks
102
+ WHERE canonical = true
103
+ AND height > (SELECT MAX(height) - ${windowSize} FROM blocks WHERE canonical = true)
104
+ )
105
+ SELECT b.height, b.hash, p.hash AS expected_parent, b.parent_hash AS stored_parent
106
+ FROM recent b
107
+ JOIN recent p ON p.height = b.height - 1
108
+ WHERE b.parent_hash <> p.hash
109
+ ORDER BY b.height
110
+ ${limitClause}
111
+ `.execute(db);
112
+ return rows.map((r) => ({
113
+ height: Number(r.height),
114
+ hash: r.hash,
115
+ expectedParent: r.expected_parent,
116
+ storedParent: r.stored_parent
117
+ }));
118
+ }
95
119
  export {
96
120
  findGaps,
121
+ findBrokenLinks,
97
122
  countMissingBlocks,
98
123
  computeContiguousTip,
99
124
  checkChainDataIntegrity
100
125
  };
101
126
 
102
- //# debugId=EADA898ADC77C59264756E2164756E21
127
+ //# debugId=FFA2BEF866239EA364756E2164756E21
103
128
  //# sourceMappingURL=integrity.js.map
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/db/queries/integrity.ts"],
4
4
  "sourcesContent": [
5
- "import { type Kysely, sql } from \"kysely\";\nimport type { Database } from \"../types.ts\";\n\nexport interface Gap {\n\tgapStart: number;\n\tgapEnd: number;\n\tsize: number;\n}\n\nexport async function findGaps(\n\tdb: Kysely<Database>,\n\tlimit?: number,\n): Promise<Gap[]> {\n\tconst limitClause = limit ? sql`LIMIT ${limit}` : sql``;\n\tconst { rows } = await sql<{\n\t\tgap_start: string;\n\t\tgap_end: string;\n\t\tsize: string;\n\t}>`\n SELECT gap_start, gap_end, gap_end - gap_start + 1 AS size\n FROM (\n SELECT height + 1 AS gap_start, next_height - 1 AS gap_end\n FROM (\n SELECT height, LEAD(height) OVER (ORDER BY height) AS next_height\n FROM blocks WHERE canonical = true\n ) sub\n WHERE next_height - height > 1\n ) gaps\n ORDER BY gap_start\n ${limitClause}\n `.execute(db);\n\n\treturn rows.map((r) => ({\n\t\tgapStart: Number(r.gap_start),\n\t\tgapEnd: Number(r.gap_end),\n\t\tsize: Number(r.size),\n\t}));\n}\n\nexport async function countMissingBlocks(\n\tdb: Kysely<Database>,\n): Promise<number> {\n\tconst { rows } = await sql<{ total: string }>`\n SELECT COALESCE(SUM(next_height - height - 1), 0) AS total\n FROM (\n SELECT height, LEAD(height) OVER (ORDER BY height) AS next_height\n FROM blocks WHERE canonical = true\n ) sub\n WHERE next_height - height > 1\n `.execute(db);\n\n\treturn Number(rows[0]?.total ?? 0);\n}\n\nexport interface ChainDataIntegrity {\n\tok: boolean;\n\tmaxHeight: number;\n\tsampleHeight: number | null;\n\tsampleBlocks: number;\n\treason: string | null;\n}\n\n/**\n * Cheap sanity check that the `blocks` table actually holds the history its tip\n * implies. Catches a wrong/empty Postgres volume — e.g. a container recreated\n * against a fresh volume — being silently served: if the tip is high but a\n * window of blocks well below it is missing, the data isn't what the tip claims.\n *\n * Below `checkFloor` we can't distinguish a fresh install from an empty volume,\n * so we report ok — a genuinely new DB legitimately has little history. Two\n * indexed lookups, safe to run on every startup / health poll.\n */\nexport async function checkChainDataIntegrity(\n\tdb: Kysely<Database>,\n\topts?: { checkFloor?: number; lookback?: number },\n): Promise<ChainDataIntegrity> {\n\tconst checkFloor = opts?.checkFloor ?? 1_000_000;\n\tconst lookback = opts?.lookback ?? 500_000;\n\n\tconst { rows: tipRows } = await sql<{ max: string | null }>`\n\t\tSELECT MAX(height) AS max FROM blocks WHERE canonical = true\n\t`.execute(db);\n\tconst maxHeight = Number(tipRows[0]?.max ?? 0);\n\n\tif (maxHeight < checkFloor) {\n\t\treturn {\n\t\t\tok: true,\n\t\t\tmaxHeight,\n\t\t\tsampleHeight: null,\n\t\t\tsampleBlocks: 0,\n\t\t\treason: null,\n\t\t};\n\t}\n\n\tconst sampleHeight = maxHeight - lookback;\n\tconst { rows: sampleRows } = await sql<{ n: string }>`\n\t\tSELECT count(*) AS n FROM blocks\n\t\tWHERE canonical = true AND height >= ${sampleHeight} AND height < ${sampleHeight + 1000}\n\t`.execute(db);\n\tconst sampleBlocks = Number(sampleRows[0]?.n ?? 0);\n\n\treturn {\n\t\tok: sampleBlocks > 0,\n\t\tmaxHeight,\n\t\tsampleHeight,\n\t\tsampleBlocks,\n\t\treason:\n\t\t\tsampleBlocks > 0\n\t\t\t\t? null\n\t\t\t\t: `tip is ${maxHeight} but no canonical blocks near ${sampleHeight} — wrong or empty volume?`,\n\t};\n}\n\nexport async function computeContiguousTip(\n\tdb: Kysely<Database>,\n\tfromHeight: number,\n): Promise<number> {\n\tconst { rows } = await sql<{ tip: string }>`\n SELECT COALESCE(MAX(height), ${fromHeight}) AS tip\n FROM (\n SELECT height, height - ROW_NUMBER() OVER (ORDER BY height) AS grp\n FROM blocks WHERE canonical = true AND height >= ${fromHeight}\n ) sub\n WHERE grp = (\n SELECT height - ROW_NUMBER() OVER (ORDER BY height)\n FROM blocks WHERE canonical = true AND height = ${fromHeight}\n )\n `.execute(db);\n\n\treturn Number(rows[0]?.tip ?? fromHeight);\n}\n"
5
+ "import { type Kysely, sql } from \"kysely\";\nimport type { Database } from \"../types.ts\";\n\nexport interface Gap {\n\tgapStart: number;\n\tgapEnd: number;\n\tsize: number;\n}\n\nexport async function findGaps(\n\tdb: Kysely<Database>,\n\tlimit?: number,\n): Promise<Gap[]> {\n\tconst limitClause = limit ? sql`LIMIT ${limit}` : sql``;\n\tconst { rows } = await sql<{\n\t\tgap_start: string;\n\t\tgap_end: string;\n\t\tsize: string;\n\t}>`\n SELECT gap_start, gap_end, gap_end - gap_start + 1 AS size\n FROM (\n SELECT height + 1 AS gap_start, next_height - 1 AS gap_end\n FROM (\n SELECT height, LEAD(height) OVER (ORDER BY height) AS next_height\n FROM blocks WHERE canonical = true\n ) sub\n WHERE next_height - height > 1\n ) gaps\n ORDER BY gap_start\n ${limitClause}\n `.execute(db);\n\n\treturn rows.map((r) => ({\n\t\tgapStart: Number(r.gap_start),\n\t\tgapEnd: Number(r.gap_end),\n\t\tsize: Number(r.size),\n\t}));\n}\n\nexport async function countMissingBlocks(\n\tdb: Kysely<Database>,\n): Promise<number> {\n\tconst { rows } = await sql<{ total: string }>`\n SELECT COALESCE(SUM(next_height - height - 1), 0) AS total\n FROM (\n SELECT height, LEAD(height) OVER (ORDER BY height) AS next_height\n FROM blocks WHERE canonical = true\n ) sub\n WHERE next_height - height > 1\n `.execute(db);\n\n\treturn Number(rows[0]?.total ?? 0);\n}\n\nexport interface ChainDataIntegrity {\n\tok: boolean;\n\tmaxHeight: number;\n\tsampleHeight: number | null;\n\tsampleBlocks: number;\n\treason: string | null;\n}\n\n/**\n * Cheap sanity check that the `blocks` table actually holds the history its tip\n * implies. Catches a wrong/empty Postgres volume — e.g. a container recreated\n * against a fresh volume — being silently served: if the tip is high but a\n * window of blocks well below it is missing, the data isn't what the tip claims.\n *\n * Below `checkFloor` we can't distinguish a fresh install from an empty volume,\n * so we report ok — a genuinely new DB legitimately has little history. Two\n * indexed lookups, safe to run on every startup / health poll.\n */\nexport async function checkChainDataIntegrity(\n\tdb: Kysely<Database>,\n\topts?: { checkFloor?: number; lookback?: number },\n): Promise<ChainDataIntegrity> {\n\tconst checkFloor = opts?.checkFloor ?? 1_000_000;\n\tconst lookback = opts?.lookback ?? 500_000;\n\n\tconst { rows: tipRows } = await sql<{ max: string | null }>`\n\t\tSELECT MAX(height) AS max FROM blocks WHERE canonical = true\n\t`.execute(db);\n\tconst maxHeight = Number(tipRows[0]?.max ?? 0);\n\n\tif (maxHeight < checkFloor) {\n\t\treturn {\n\t\t\tok: true,\n\t\t\tmaxHeight,\n\t\t\tsampleHeight: null,\n\t\t\tsampleBlocks: 0,\n\t\t\treason: null,\n\t\t};\n\t}\n\n\tconst sampleHeight = maxHeight - lookback;\n\tconst { rows: sampleRows } = await sql<{ n: string }>`\n\t\tSELECT count(*) AS n FROM blocks\n\t\tWHERE canonical = true AND height >= ${sampleHeight} AND height < ${sampleHeight + 1000}\n\t`.execute(db);\n\tconst sampleBlocks = Number(sampleRows[0]?.n ?? 0);\n\n\treturn {\n\t\tok: sampleBlocks > 0,\n\t\tmaxHeight,\n\t\tsampleHeight,\n\t\tsampleBlocks,\n\t\treason:\n\t\t\tsampleBlocks > 0\n\t\t\t\t? null\n\t\t\t\t: `tip is ${maxHeight} but no canonical blocks near ${sampleHeight} — wrong or empty volume?`,\n\t};\n}\n\nexport async function computeContiguousTip(\n\tdb: Kysely<Database>,\n\tfromHeight: number,\n): Promise<number> {\n\tconst { rows } = await sql<{ tip: string }>`\n SELECT COALESCE(MAX(height), ${fromHeight}) AS tip\n FROM (\n SELECT height, height - ROW_NUMBER() OVER (ORDER BY height) AS grp\n FROM blocks WHERE canonical = true AND height >= ${fromHeight}\n ) sub\n WHERE grp = (\n SELECT height - ROW_NUMBER() OVER (ORDER BY height)\n FROM blocks WHERE canonical = true AND height = ${fromHeight}\n )\n `.execute(db);\n\n\treturn Number(rows[0]?.tip ?? fromHeight);\n}\n\nexport type BrokenLink = {\n\theight: number;\n\thash: string;\n\texpectedParent: string;\n\tstoredParent: string;\n};\n\n/**\n * Canonical heights whose block does not descend from the canonical block\n * below it.\n *\n * `findGaps` asks \"is every height present?\", which a chain sitting on a losing\n * fork answers yes to. On 2026-07-30 a reorg at 8,663,166 was resolved the\n * wrong way: we adopted a block that mainnet orphaned, its true successors were\n * swept non-canonical, and later blocks were inserted canonical on top. Every\n * height existed. Nothing was \"missing\". The chain simply did not join up, and\n * no check asked that question — so we served a broken chain for seventeen\n * hours and only noticed because subgraphs stopped moving.\n *\n * Scoped to a recent window by default: an unlinked height is an active-repair\n * concern, and scanning eight million rows every cycle is not.\n */\nexport async function findBrokenLinks(\n\tdb: Kysely<Database>,\n\topts: { window?: number; limit?: number } = {},\n): Promise<BrokenLink[]> {\n\tconst windowSize = opts.window ?? 10_000;\n\tconst limitClause = opts.limit ? sql`LIMIT ${opts.limit}` : sql``;\n\tconst { rows } = await sql<{\n\t\theight: string | number;\n\t\thash: string;\n\t\texpected_parent: string;\n\t\tstored_parent: string;\n\t}>`\n\t\tWITH recent AS (\n\t\t\tSELECT height, hash, parent_hash\n\t\t\tFROM blocks\n\t\t\tWHERE canonical = true\n\t\t\t\tAND height > (SELECT MAX(height) - ${windowSize} FROM blocks WHERE canonical = true)\n\t\t)\n\t\tSELECT b.height, b.hash, p.hash AS expected_parent, b.parent_hash AS stored_parent\n\t\tFROM recent b\n\t\tJOIN recent p ON p.height = b.height - 1\n\t\tWHERE b.parent_hash <> p.hash\n\t\tORDER BY b.height\n\t\t${limitClause}\n\t`.execute(db);\n\treturn rows.map((r) => ({\n\t\theight: Number(r.height),\n\t\thash: r.hash,\n\t\texpectedParent: r.expected_parent,\n\t\tstoredParent: r.stored_parent,\n\t}));\n}\n"
6
6
  ],
7
- "mappings": ";;;;;;;;;;;;;;;;;AAAA;AASA,eAAsB,QAAQ,CAC7B,IACA,OACiB;AAAA,EACjB,MAAM,cAAc,QAAQ,YAAY,UAAU;AAAA,EAClD,QAAQ,SAAS,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAelB;AAAA,IACF,QAAQ,EAAE;AAAA,EAEb,OAAO,KAAK,IAAI,CAAC,OAAO;AAAA,IACvB,UAAU,OAAO,EAAE,SAAS;AAAA,IAC5B,QAAQ,OAAO,EAAE,OAAO;AAAA,IACxB,MAAM,OAAO,EAAE,IAAI;AAAA,EACpB,EAAE;AAAA;AAGH,eAAsB,kBAAkB,CACvC,IACkB;AAAA,EAClB,QAAQ,SAAS,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOpB,QAAQ,EAAE;AAAA,EAEb,OAAO,OAAO,KAAK,IAAI,SAAS,CAAC;AAAA;AAqBlC,eAAsB,uBAAuB,CAC5C,IACA,MAC8B;AAAA,EAC9B,MAAM,aAAa,MAAM,cAAc;AAAA,EACvC,MAAM,WAAW,MAAM,YAAY;AAAA,EAEnC,QAAQ,MAAM,YAAY,MAAM;AAAA;AAAA,GAE9B,QAAQ,EAAE;AAAA,EACZ,MAAM,YAAY,OAAO,QAAQ,IAAI,OAAO,CAAC;AAAA,EAE7C,IAAI,YAAY,YAAY;AAAA,IAC3B,OAAO;AAAA,MACN,IAAI;AAAA,MACJ;AAAA,MACA,cAAc;AAAA,MACd,cAAc;AAAA,MACd,QAAQ;AAAA,IACT;AAAA,EACD;AAAA,EAEA,MAAM,eAAe,YAAY;AAAA,EACjC,QAAQ,MAAM,eAAe,MAAM;AAAA;AAAA,yCAEK,6BAA6B,eAAe;AAAA,GAClF,QAAQ,EAAE;AAAA,EACZ,MAAM,eAAe,OAAO,WAAW,IAAI,KAAK,CAAC;AAAA,EAEjD,OAAO;AAAA,IACN,IAAI,eAAe;AAAA,IACnB;AAAA,IACA;AAAA,IACA;AAAA,IACA,QACC,eAAe,IACZ,OACA,UAAU,0CAA0C;AAAA,EACzD;AAAA;AAGD,eAAsB,oBAAoB,CACzC,IACA,YACkB;AAAA,EAClB,QAAQ,SAAS,MAAM;AAAA,mCACW;AAAA;AAAA;AAAA,yDAGsB;AAAA;AAAA;AAAA;AAAA,wDAID;AAAA;AAAA,IAEpD,QAAQ,EAAE;AAAA,EAEb,OAAO,OAAO,KAAK,IAAI,OAAO,UAAU;AAAA;",
8
- "debugId": "EADA898ADC77C59264756E2164756E21",
7
+ "mappings": ";;;;;;;;;;;;;;;;;AAAA;AASA,eAAsB,QAAQ,CAC7B,IACA,OACiB;AAAA,EACjB,MAAM,cAAc,QAAQ,YAAY,UAAU;AAAA,EAClD,QAAQ,SAAS,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAelB;AAAA,IACF,QAAQ,EAAE;AAAA,EAEb,OAAO,KAAK,IAAI,CAAC,OAAO;AAAA,IACvB,UAAU,OAAO,EAAE,SAAS;AAAA,IAC5B,QAAQ,OAAO,EAAE,OAAO;AAAA,IACxB,MAAM,OAAO,EAAE,IAAI;AAAA,EACpB,EAAE;AAAA;AAGH,eAAsB,kBAAkB,CACvC,IACkB;AAAA,EAClB,QAAQ,SAAS,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOpB,QAAQ,EAAE;AAAA,EAEb,OAAO,OAAO,KAAK,IAAI,SAAS,CAAC;AAAA;AAqBlC,eAAsB,uBAAuB,CAC5C,IACA,MAC8B;AAAA,EAC9B,MAAM,aAAa,MAAM,cAAc;AAAA,EACvC,MAAM,WAAW,MAAM,YAAY;AAAA,EAEnC,QAAQ,MAAM,YAAY,MAAM;AAAA;AAAA,GAE9B,QAAQ,EAAE;AAAA,EACZ,MAAM,YAAY,OAAO,QAAQ,IAAI,OAAO,CAAC;AAAA,EAE7C,IAAI,YAAY,YAAY;AAAA,IAC3B,OAAO;AAAA,MACN,IAAI;AAAA,MACJ;AAAA,MACA,cAAc;AAAA,MACd,cAAc;AAAA,MACd,QAAQ;AAAA,IACT;AAAA,EACD;AAAA,EAEA,MAAM,eAAe,YAAY;AAAA,EACjC,QAAQ,MAAM,eAAe,MAAM;AAAA;AAAA,yCAEK,6BAA6B,eAAe;AAAA,GAClF,QAAQ,EAAE;AAAA,EACZ,MAAM,eAAe,OAAO,WAAW,IAAI,KAAK,CAAC;AAAA,EAEjD,OAAO;AAAA,IACN,IAAI,eAAe;AAAA,IACnB;AAAA,IACA;AAAA,IACA;AAAA,IACA,QACC,eAAe,IACZ,OACA,UAAU,0CAA0C;AAAA,EACzD;AAAA;AAGD,eAAsB,oBAAoB,CACzC,IACA,YACkB;AAAA,EAClB,QAAQ,SAAS,MAAM;AAAA,mCACW;AAAA;AAAA;AAAA,yDAGsB;AAAA;AAAA;AAAA;AAAA,wDAID;AAAA;AAAA,IAEpD,QAAQ,EAAE;AAAA,EAEb,OAAO,OAAO,KAAK,IAAI,OAAO,UAAU;AAAA;AAyBzC,eAAsB,eAAe,CACpC,IACA,OAA4C,CAAC,GACrB;AAAA,EACxB,MAAM,aAAa,KAAK,UAAU;AAAA,EAClC,MAAM,cAAc,KAAK,QAAQ,YAAY,KAAK,UAAU;AAAA,EAC5D,QAAQ,SAAS,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA,yCAUiB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOrC;AAAA,GACD,QAAQ,EAAE;AAAA,EACZ,OAAO,KAAK,IAAI,CAAC,OAAO;AAAA,IACvB,QAAQ,OAAO,EAAE,MAAM;AAAA,IACvB,MAAM,EAAE;AAAA,IACR,gBAAgB,EAAE;AAAA,IAClB,cAAc,EAAE;AAAA,EACjB,EAAE;AAAA;",
8
+ "debugId": "FFA2BEF866239EA364756E2164756E21",
9
9
  "names": []
10
10
  }
@@ -682,6 +682,21 @@ interface ChainReadCacheTable {
682
682
  result_hex: string;
683
683
  created_at: Generated<Date>;
684
684
  }
685
+ /**
686
+ * A block that arrived for a height we already hold, staged until a later
687
+ * block's `parent_hash` says which of the two the chain kept. See migration
688
+ * 0112 — adopting on first sight picked the losing fork twice.
689
+ */
690
+ interface PendingForkBlocksTable {
691
+ height: number;
692
+ block_hash: string;
693
+ parent_hash: string;
694
+ /** The canonical hash we held at this height when the contender arrived. */
695
+ incumbent_hash: string;
696
+ /** The raw observer payload, replayed verbatim if this fork wins. */
697
+ payload: ColumnType<unknown, unknown, unknown>;
698
+ received_at: Generated<Date>;
699
+ }
685
700
  interface Database {
686
701
  blocks: BlocksTable;
687
702
  transactions: TransactionsTable;
@@ -744,6 +759,7 @@ interface Database {
744
759
  x402_payments: X402PaymentsTable;
745
760
  x402_balances: X402BalancesTable;
746
761
  chain_read_cache: ChainReadCacheTable;
762
+ pending_fork_blocks: PendingForkBlocksTable;
747
763
  }
748
764
  /** Prepaid x402 credit — one running USD-micros balance per payer principal. */
749
765
  interface X402BalancesTable {
@@ -682,6 +682,21 @@ interface ChainReadCacheTable {
682
682
  result_hex: string;
683
683
  created_at: Generated<Date>;
684
684
  }
685
+ /**
686
+ * A block that arrived for a height we already hold, staged until a later
687
+ * block's `parent_hash` says which of the two the chain kept. See migration
688
+ * 0112 — adopting on first sight picked the losing fork twice.
689
+ */
690
+ interface PendingForkBlocksTable {
691
+ height: number;
692
+ block_hash: string;
693
+ parent_hash: string;
694
+ /** The canonical hash we held at this height when the contender arrived. */
695
+ incumbent_hash: string;
696
+ /** The raw observer payload, replayed verbatim if this fork wins. */
697
+ payload: ColumnType<unknown, unknown, unknown>;
698
+ received_at: Generated<Date>;
699
+ }
685
700
  interface Database {
686
701
  blocks: BlocksTable;
687
702
  transactions: TransactionsTable;
@@ -744,6 +759,7 @@ interface Database {
744
759
  x402_payments: X402PaymentsTable;
745
760
  x402_balances: X402BalancesTable;
746
761
  chain_read_cache: ChainReadCacheTable;
762
+ pending_fork_blocks: PendingForkBlocksTable;
747
763
  }
748
764
  /** Prepaid x402 credit — one running USD-micros balance per payer principal. */
749
765
  interface X402BalancesTable {
@@ -683,6 +683,21 @@ interface ChainReadCacheTable {
683
683
  result_hex: string;
684
684
  created_at: Generated<Date>;
685
685
  }
686
+ /**
687
+ * A block that arrived for a height we already hold, staged until a later
688
+ * block's `parent_hash` says which of the two the chain kept. See migration
689
+ * 0112 — adopting on first sight picked the losing fork twice.
690
+ */
691
+ interface PendingForkBlocksTable {
692
+ height: number;
693
+ block_hash: string;
694
+ parent_hash: string;
695
+ /** The canonical hash we held at this height when the contender arrived. */
696
+ incumbent_hash: string;
697
+ /** The raw observer payload, replayed verbatim if this fork wins. */
698
+ payload: ColumnType<unknown, unknown, unknown>;
699
+ received_at: Generated<Date>;
700
+ }
686
701
  interface Database {
687
702
  blocks: BlocksTable;
688
703
  transactions: TransactionsTable;
@@ -745,6 +760,7 @@ interface Database {
745
760
  x402_payments: X402PaymentsTable;
746
761
  x402_balances: X402BalancesTable;
747
762
  chain_read_cache: ChainReadCacheTable;
763
+ pending_fork_blocks: PendingForkBlocksTable;
748
764
  }
749
765
  /** Prepaid x402 credit — one running USD-micros balance per payer principal. */
750
766
  interface X402BalancesTable {
@@ -1015,6 +1031,22 @@ declare function listSubgraphs(db: Kysely<Database>, accountId?: string): Promis
1015
1031
  declare function findPublicSubgraphByName(db: Kysely<Database>, name: string): Promise<Subgraph | null>;
1016
1032
  declare function updateSubgraphVisibility(db: Kysely<Database>, name: string, accountId: string, visibility: "public" | "private"): Promise<void>;
1017
1033
  declare function updateSubgraphStatus(db: Kysely<Database>, name: string, status: string, lastProcessedBlock?: number): Promise<void>;
1034
+ /** Live-walk ERROR write (f069): a block whose handlers ALL failed
1035
+ * (`processed === 0 && errors > 0`, `block-processor.ts`'s `applyProgress`)
1036
+ * is stamped 'error' and its height is recorded as processed, same as
1037
+ * `updateSubgraphStatus(db, name, "error", blockHeight)` — except the
1038
+ * cursor half of that write now goes through the same conditional `<`
1039
+ * guard as {@link recordLiveProgress}. Marking a subgraph 'error' is not
1040
+ * itself replay-sensitive, but it shares the `last_processed_block` column
1041
+ * with the replay guard: an unconditional write here could regress the
1042
+ * cursor a racing (successful) writer already advanced past, which is
1043
+ * exactly the regress-then-re-walk loop this guard exists to close. In
1044
+ * practice `ctx.rollbackTo` (`runner.ts`) means an all-failed block never
1045
+ * flushes writes, so a lost race here is always a silent no-op, never a
1046
+ * case that needs `CursorRaceLostError`-style tx abort. Returns whether
1047
+ * the write applied. Live path only — reindex/backfill's error stamping
1048
+ * goes through {@link updateSubgraphStatus} unconditionally, as before. */
1049
+ declare function recordLiveError(db: Kysely<Database>, name: string, blockHeight: number): Promise<boolean>;
1018
1050
  declare function recordSubgraphProcessed(db: Kysely<Database>, name: string, processed: number, errors: number, lastError?: string): Promise<void>;
1019
1051
  declare function updateSubgraphHandlerPath(db: Kysely<Database>, name: string, handlerPath: string, opts?: {
1020
1052
  handlerCode?: string
@@ -1029,16 +1061,49 @@ declare function updateSubgraphExpiry(db: Kysely<Database>, name: string, accoun
1029
1061
  * status stamping let catch-up flap a parked subgraph back into its own path
1030
1062
  * per block, fighting the queued reindex op.
1031
1063
  *
1032
- * `last_processed_block` itself is written UNCONDITIONALLY — no monotonic
1033
- * guard — on purpose: a reorg rewind must be able to move it *backward* to
1034
- * the fork height, and a naive GREATEST(old, new) would block that. This
1035
- * only stays correct because the two writers that can advance/rewind a
1036
- * given subgraph's cursor concurrently (the catch-up walk and the reorg
1037
- * handler, both in `@secondlayer/subgraphs/runtime`) serialize on an
1038
- * in-process per-subgraph lock + reorg epoch guard
1039
- * (`packages/subgraphs/src/runtime/catchup.ts`, `subgraphBlockLockHeld` /
1040
- * `reorgEpoch` — see the comment there for the full invariant; f057). Don't
1041
- * add a monotonic guard here without re-deriving that it can't also block
1042
- * the rewind. */
1043
- declare function recordLiveProgress(db: Kysely<Database>, name: string, lastProcessedBlock: number): Promise<void>;
1044
- export { updateSubgraphVisibility, updateSubgraphStatus, updateSubgraphHandlerPath, updateSubgraphExpiry, subgraphDatabaseUrl, resolveSubgraphRawClient, resolveSubgraphDb, registerSubgraph, recordSubgraphProcessed, recordLiveProgress, pgSchemaNameFor, pgSchemaName, listSubgraphs, isByoSubgraph, getSubgraph, findPublicSubgraphByName, encryptDatabaseUrl, deleteSubgraph };
1064
+ * `last_processed_block` is now written CONDITIONALLY — `WHERE
1065
+ * last_processed_block < lastProcessedBlock`, mirroring
1066
+ * `advanceOperationCursor`'s (`subgraph-operations.ts`) comparison shape —
1067
+ * and returns whether THIS call actually advanced it. This used to be
1068
+ * unconditional by design, on the premise that the only two writers able to
1069
+ * advance/rewind a given subgraph's cursor concurrently (the catch-up walk
1070
+ * and the reorg handler) serialize on an in-process per-subgraph lock +
1071
+ * reorg epoch guard (`packages/subgraphs/src/runtime/catchup.ts`,
1072
+ * `subgraphBlockLockHeld` / `reorgEpoch`; f057). That guard is in-process
1073
+ * only. The f068 investigation
1074
+ * (`docs/internal/audits/asset-holdings-unreproducible-balances-f068.md`)
1075
+ * found a cross-process sequence it does not cover: a catch-up leader that
1076
+ * loses its advisory-lock connection mid-walk keeps writing (leadership is
1077
+ * checked only at walk entry, not per-iteration) while a new leader takes
1078
+ * over and walks the same range — both commit increments for overlapping
1079
+ * heights, and every regressed cursor write from the laggard re-triggers a
1080
+ * full re-walk, growing per-height application without bound. The
1081
+ * conditional advance here (paired with the fast-path replay guard armed on
1082
+ * the live path in `block-processor.ts`) is now the cross-process
1083
+ * guarantee: a block at/below the current cursor can never re-advance it,
1084
+ * so a laggard's commits can no longer regress-then-retrigger.
1085
+ *
1086
+ * This conditionality is why the reorg rewind can no longer go through this
1087
+ * function — a rewind's whole point is to move the cursor *backward*, which
1088
+ * the `<` guard would silently refuse. Reorg mode calls
1089
+ * {@link rewindLiveProgress} instead, which keeps the old unconditional
1090
+ * semantics and is safe specifically because the reorg path takes a
1091
+ * transaction-scoped advisory lock (`reorg.ts`) other writers can't cross. */
1092
+ declare function recordLiveProgress(db: Kysely<Database>, name: string, lastProcessedBlock: number): Promise<boolean>;
1093
+ /** Reorg-rewind progress write: the OLD unconditional `recordLiveProgress`
1094
+ * semantics, verbatim — moves `last_processed_block` to the fork height
1095
+ * regardless of its current value, because that is by definition a
1096
+ * backward move the conditional `<` guard in `recordLiveProgress` would
1097
+ * refuse. Same status-promotion CASE (never unparks an explicit
1098
+ * 'reindexing').
1099
+ *
1100
+ * Callers: exclusively the subgraph-reorg path
1101
+ * (`packages/subgraphs/src/runtime/reorg.ts`, via `block-processor.ts`'s
1102
+ * `reorgRewind` mode), which is safe from the cross-process race this
1103
+ * function reintroduces because it holds a transaction-scoped Postgres
1104
+ * advisory lock (`pg_advisory_xact_lock`) for the whole
1105
+ * delete+reprocess+rewind sequence — no other writer can interleave a
1106
+ * forward write between the rewind and the fork block's reprocess. Do not
1107
+ * call this from the live catch-up path. */
1108
+ declare function rewindLiveProgress(db: Kysely<Database>, name: string, lastProcessedBlock: number): Promise<void>;
1109
+ export { updateSubgraphVisibility, updateSubgraphStatus, updateSubgraphHandlerPath, updateSubgraphExpiry, subgraphDatabaseUrl, rewindLiveProgress, resolveSubgraphRawClient, resolveSubgraphDb, registerSubgraph, recordSubgraphProcessed, recordLiveProgress, recordLiveError, pgSchemaNameFor, pgSchemaName, listSubgraphs, isByoSubgraph, getSubgraph, findPublicSubgraphByName, encryptDatabaseUrl, deleteSubgraph };
@@ -657,6 +657,7 @@ var TABLE_TO_DB = {
657
657
  contracts: "source",
658
658
  chain_reorgs: "source",
659
659
  chain_read_cache: "source",
660
+ pending_fork_blocks: "source",
660
661
  decoded_events: "source",
661
662
  decoder_checkpoints: "source",
662
663
  pox4_calls: "source",
@@ -1092,6 +1093,14 @@ async function updateSubgraphStatus(db, name, status, lastProcessedBlock) {
1092
1093
  updated_at: new Date
1093
1094
  }).where("name", "=", name).execute();
1094
1095
  }
1096
+ async function recordLiveError(db, name, blockHeight) {
1097
+ const result = await db.updateTable("subgraphs").set({
1098
+ status: "error",
1099
+ last_processed_block: blockHeight,
1100
+ updated_at: new Date
1101
+ }).where("name", "=", name).where("last_processed_block", "<", blockHeight).executeTakeFirst();
1102
+ return Number(result.numUpdatedRows ?? 0n) > 0;
1103
+ }
1095
1104
  async function recordSubgraphProcessed(db, name, processed, errors, lastError) {
1096
1105
  await db.updateTable("subgraphs").set({
1097
1106
  total_processed: sql3`total_processed + ${processed}`,
@@ -1129,6 +1138,14 @@ async function updateSubgraphExpiry(db, name, accountId, expiresAt) {
1129
1138
  await db.updateTable("subgraphs").set({ expires_at: expiresAt }).where("name", "=", name).where("account_id", "=", accountId).execute();
1130
1139
  }
1131
1140
  async function recordLiveProgress(db, name, lastProcessedBlock) {
1141
+ const result = await db.updateTable("subgraphs").set({
1142
+ last_processed_block: lastProcessedBlock,
1143
+ status: sql3`CASE WHEN status = 'reindexing' THEN status ELSE 'active' END`,
1144
+ updated_at: new Date
1145
+ }).where("name", "=", name).where("last_processed_block", "<", lastProcessedBlock).executeTakeFirst();
1146
+ return Number(result.numUpdatedRows ?? 0n) > 0;
1147
+ }
1148
+ async function rewindLiveProgress(db, name, lastProcessedBlock) {
1132
1149
  await db.updateTable("subgraphs").set({
1133
1150
  last_processed_block: lastProcessedBlock,
1134
1151
  status: sql3`CASE WHEN status = 'reindexing' THEN status ELSE 'active' END`,
@@ -1141,11 +1158,13 @@ export {
1141
1158
  updateSubgraphHandlerPath,
1142
1159
  updateSubgraphExpiry,
1143
1160
  subgraphDatabaseUrl,
1161
+ rewindLiveProgress,
1144
1162
  resolveSubgraphRawClient,
1145
1163
  resolveSubgraphDb,
1146
1164
  registerSubgraph,
1147
1165
  recordSubgraphProcessed,
1148
1166
  recordLiveProgress,
1167
+ recordLiveError,
1149
1168
  pgSchemaNameFor,
1150
1169
  pgSchemaName,
1151
1170
  listSubgraphs,
@@ -1156,5 +1175,5 @@ export {
1156
1175
  deleteSubgraph
1157
1176
  };
1158
1177
 
1159
- //# debugId=2EF0B7E8B91D856C64756E2164756E21
1178
+ //# debugId=3C4CE031C9F5B5D364756E2164756E21
1160
1179
  //# sourceMappingURL=subgraphs.js.map