@secondlayer/subgraphs 6.2.4 → 6.2.6

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/dist/index.js.map CHANGED
@@ -13,7 +13,7 @@
13
13
  "import type {\n\tColumnType,\n\tSubgraphDefinition,\n\tSubgraphTable,\n} from \"../types.ts\";\nimport { pascalCase, pgSchemaName } from \"./utils.ts\";\n\n/**\n * Generate Kysely table interfaces from a subgraph definition — the Kysely arm of\n * the \"Prisma wrapper for Stacks\" story (alongside prisma.ts/drizzle.ts). Emits a\n * per-table interface plus a `DB` registry keyed by schema-qualified table name,\n * so `new Kysely<DB>()` gives fully-typed query building over BYO decoded rows.\n * Output mirrors the deployed DDL in generator.ts — tables are processor-written,\n * so treat them read-only. Column keys stay as the real DB names (Kysely maps to\n * physical columns, unlike the camel-cased Prisma/Drizzle models).\n */\n\n/** Subgraph column type → Kysely TS type. numeric/bigint serialize as string (lossless for Clarity uint128/int128). */\nconst KYSELY_TYPE: Record<ColumnType, string> = {\n\tuint: \"string\",\n\tint: \"string\",\n\ttext: \"string\",\n\tprincipal: \"string\",\n\tboolean: \"boolean\",\n\ttimestamp: \"Date\",\n\tjsonb: \"unknown\",\n};\n\n/** System columns every subgraph table gets (kept in sync with generator.ts). */\nconst SYSTEM_FIELDS: Array<{ col: string; type: string }> = [\n\t{ col: \"_id\", type: \"Generated<string>\" },\n\t{ col: \"_block_height\", type: \"string\" },\n\t{ col: \"_tx_id\", type: \"string\" },\n\t{ col: \"_created_at\", type: \"Generated<Date>\" },\n];\n\nfunction renderTable(tableName: string, table: SubgraphTable): string {\n\tconst lines = SYSTEM_FIELDS.map((f) => ` ${f.col}: ${f.type};`);\n\tfor (const [colName, col] of Object.entries(table.columns)) {\n\t\tlet type = KYSELY_TYPE[col.type];\n\t\tif (col.nullable) type = `${type} | null`;\n\t\t// A DB-side default makes the column optional on insert — Generated<> models that.\n\t\tif (col.default !== undefined) type = `Generated<${type}>`;\n\t\tlines.push(` ${colName}: ${type};`);\n\t}\n\treturn `export interface ${pascalCase(tableName)} {\\n${lines.join(\"\\n\")}\\n}`;\n}\n\nexport interface KyselyGenOptions {\n\t/** Postgres schema the tables live in (account-scoped). */\n\tschemaName?: string;\n}\n\nexport function generateKyselySchema(\n\tdef: SubgraphDefinition,\n\topts: KyselyGenOptions = {},\n): string {\n\tconst schemaName = opts.schemaName ?? pgSchemaName(def.name);\n\n\tconst interfaces = Object.entries(def.schema).map(([name, table]) =>\n\t\trenderTable(name, table),\n\t);\n\n\t// DB registry — schema-qualified keys so `db.selectFrom(\"schema.table\")` resolves.\n\tconst dbEntries = Object.keys(def.schema).map(\n\t\t(name) => ` \"${schemaName}.${name}\": ${pascalCase(name)};`,\n\t);\n\tconst dbRegistry = `export interface DB {\\n${dbEntries.join(\"\\n\")}\\n}`;\n\n\t// Selectable row aliases for ergonomic typed reads.\n\tconst rowExports = Object.keys(def.schema).map(\n\t\t(name) =>\n\t\t\t`export type ${pascalCase(name)}Row = Selectable<${pascalCase(name)}>;`,\n\t);\n\n\treturn [\n\t\t`// Generated by \\`sl codegen subgraph --target kysely\\` from subgraph \"${def.name}\". Do not edit by hand.`,\n\t\t`import type { Generated, Selectable } from \"kysely\";`,\n\t\t\"\",\n\t\tinterfaces.join(\"\\n\\n\"),\n\t\t\"\",\n\t\tdbRegistry,\n\t\t\"\",\n\t\trowExports.join(\"\\n\"),\n\t\t\"\",\n\t].join(\"\\n\");\n}\n",
14
14
  "import {\n\ttype IndexColumn,\n\ttype IndexColumnType,\n\ttype IndexPrimaryKey,\n\tSOURCE_READ_PKS,\n\tSOURCE_READ_TYPES,\n} from \"@secondlayer/shared/db\";\nimport { pascalCase, snakeToCamel } from \"./utils.ts\";\n\n/**\n * Emit a portable, typed schema for the public Index read domain (blocks,\n * decoded events, transactions, stacking, sBTC, BNS, …) from the single-sourced\n * `SOURCE_READ_TYPES` registry. This is the Index arm of the \"typed Stacks data in\n * your own stack\" story: a BYO-database consumer mirrors Index rows and gets a\n * fully-typed ORM / JSON-Schema that can't drift from the API (the registry is\n * drift-tested against the producer's read contract).\n *\n * Prisma uses the `SOURCE_READ_PKS` registry for model identity. Tables whose only\n * physical PK is a synthetic id excluded from the read contract (chain_reorgs) have\n * no read-set key and are omitted from Prisma output (other targets still emit them).\n */\n\nexport type IndexCodegenTarget =\n\t| \"prisma\"\n\t| \"kysely\"\n\t| \"drizzle\"\n\t| \"json-schema\";\n\nexport interface IndexCodegenOptions {\n\t/** Restrict output to these Index tables (default: all). */\n\ttables?: string[];\n\t/** Postgres schema to qualify table names with (default: bare names). */\n\tschemaName?: string;\n\t/** Prisma only: datasource url env var (default DATABASE_URL). */\n\tdatasourceEnv?: string;\n}\n\ntype IndexTables = typeof SOURCE_READ_TYPES;\n\n/** Resolve the requested tables (validated) → ordered [table, columns] entries. */\nfunction selectTables(\n\topts: IndexCodegenOptions,\n): Array<[string, Record<string, IndexColumn>]> {\n\tconst all = SOURCE_READ_TYPES as Record<string, Record<string, IndexColumn>>;\n\tconst names = opts.tables ?? Object.keys(all);\n\treturn names.map((name) => {\n\t\tconst cols = all[name];\n\t\tif (!cols) {\n\t\t\tthrow new Error(\n\t\t\t\t`Unknown Index table \"${name}\". Known: ${Object.keys(all).sort().join(\", \")}`,\n\t\t\t);\n\t\t}\n\t\treturn [name, cols];\n\t});\n}\n\nconst KYSELY_TYPE: Record<IndexColumnType, string> = {\n\ttext: \"string\",\n\tint: \"number\",\n\tboolean: \"boolean\",\n\ttimestamp: \"Date\",\n\tjsonb: \"unknown\",\n};\n\nfunction generateKysely(\n\tentries: Array<[string, Record<string, IndexColumn>]>,\n\tschemaName?: string,\n): string {\n\tconst interfaces = entries.map(([table, cols]) => {\n\t\tconst lines = Object.entries(cols).map(([col, def]) => {\n\t\t\tconst type = def.nullable\n\t\t\t\t? `${KYSELY_TYPE[def.type]} | null`\n\t\t\t\t: KYSELY_TYPE[def.type];\n\t\t\treturn ` ${col}: ${type};`;\n\t\t});\n\t\treturn `export interface ${pascalCase(table)} {\\n${lines.join(\"\\n\")}\\n}`;\n\t});\n\tconst dbEntries = entries.map(([table]) => {\n\t\tconst key = schemaName ? `${schemaName}.${table}` : table;\n\t\treturn ` \"${key}\": ${pascalCase(table)};`;\n\t});\n\tconst rows = entries.map(\n\t\t([table]) =>\n\t\t\t`export type ${pascalCase(table)}Row = Selectable<${pascalCase(table)}>;`,\n\t);\n\treturn [\n\t\t\"// Generated by `sl codegen index --target kysely`. Do not edit by hand.\",\n\t\t`import type { Selectable } from \"kysely\";`,\n\t\t\"\",\n\t\tinterfaces.join(\"\\n\\n\"),\n\t\t\"\",\n\t\t`export interface IndexDB {\\n${dbEntries.join(\"\\n\")}\\n}`,\n\t\t\"\",\n\t\trows.join(\"\\n\"),\n\t\t\"\",\n\t].join(\"\\n\");\n}\n\nconst DRIZZLE_BUILDER: Record<IndexColumnType, (col: string) => string> = {\n\ttext: (c) => `text(\"${c}\")`,\n\tint: (c) => `integer(\"${c}\")`,\n\tboolean: (c) => `boolean(\"${c}\")`,\n\ttimestamp: (c) => `timestamp(\"${c}\", { withTimezone: true })`,\n\tjsonb: (c) => `jsonb(\"${c}\")`,\n};\n\nconst DRIZZLE_BUILDER_NAME: Record<IndexColumnType, string> = {\n\ttext: \"text\",\n\tint: \"integer\",\n\tboolean: \"boolean\",\n\ttimestamp: \"timestamp\",\n\tjsonb: \"jsonb\",\n};\n\nfunction generateDrizzle(\n\tentries: Array<[string, Record<string, IndexColumn>]>,\n\tschemaName?: string,\n): string {\n\tconst used = new Set<string>();\n\tfor (const [, cols] of entries)\n\t\tfor (const def of Object.values(cols))\n\t\t\tused.add(DRIZZLE_BUILDER_NAME[def.type]);\n\tif (schemaName) used.add(\"pgSchema\");\n\telse used.add(\"pgTable\");\n\n\tconst tableFn = schemaName ? \"sg.table\" : \"pgTable\";\n\tconst tables = entries.map(([table, cols]) => {\n\t\tconst lines = Object.entries(cols).map(([col, def]) => {\n\t\t\tlet line = ` ${snakeToCamel(col)}: ${DRIZZLE_BUILDER[def.type](col)}`;\n\t\t\tif (!def.nullable) line += \".notNull()\";\n\t\t\treturn `${line},`;\n\t\t});\n\t\treturn `export const ${snakeToCamel(table)} = ${tableFn}(\"${table}\", {\\n${lines.join(\"\\n\")}\\n});`;\n\t});\n\tconst rows = entries.map(\n\t\t([table]) =>\n\t\t\t`export type ${pascalCase(table)} = typeof ${snakeToCamel(table)}.$inferSelect;`,\n\t);\n\treturn [\n\t\t\"// Generated by `sl codegen index --target drizzle`. Do not edit by hand.\",\n\t\t`import { ${[...used].sort().join(\", \")} } from \"drizzle-orm/pg-core\";`,\n\t\t\"\",\n\t\t...(schemaName ? [`export const sg = pgSchema(\"${schemaName}\");`, \"\"] : []),\n\t\ttables.join(\"\\n\\n\"),\n\t\t\"\",\n\t\trows.join(\"\\n\"),\n\t\t\"\",\n\t].join(\"\\n\");\n}\n\nconst JSON_SCHEMA_TYPE: Record<IndexColumnType, Record<string, unknown>> = {\n\ttext: { type: \"string\" },\n\tint: { type: \"integer\" },\n\tboolean: { type: \"boolean\" },\n\ttimestamp: { type: \"string\", format: \"date-time\" },\n\tjsonb: {},\n};\n\nfunction generateJsonSchema(\n\tentries: Array<[string, Record<string, IndexColumn>]>,\n): string {\n\tconst defs: Record<string, unknown> = {};\n\tfor (const [table, cols] of entries) {\n\t\tconst properties: Record<string, unknown> = {};\n\t\tconst required: string[] = [];\n\t\tfor (const [col, def] of Object.entries(cols)) {\n\t\t\tconst base = JSON_SCHEMA_TYPE[def.type];\n\t\t\tif (def.nullable && \"type\" in base) {\n\t\t\t\tproperties[col] = { ...base, type: [base.type, \"null\"] };\n\t\t\t} else {\n\t\t\t\tproperties[col] = { ...base };\n\t\t\t\tif (!def.nullable) required.push(col);\n\t\t\t}\n\t\t}\n\t\tdefs[pascalCase(table)] = {\n\t\t\ttype: \"object\",\n\t\t\tproperties,\n\t\t\trequired,\n\t\t\tadditionalProperties: false,\n\t\t};\n\t}\n\treturn `${JSON.stringify(\n\t\t{\n\t\t\t$schema: \"https://json-schema.org/draft/2020-12/schema\",\n\t\t\ttitle: \"Secondlayer Index domain\",\n\t\t\t$defs: defs,\n\t\t},\n\t\tnull,\n\t\t2,\n\t)}\\n`;\n}\n\nconst PRISMA_TYPE: Record<IndexColumnType, { type: string; db?: string }> = {\n\ttext: { type: \"String\" },\n\tint: { type: \"Int\" },\n\tboolean: { type: \"Boolean\" },\n\ttimestamp: { type: \"DateTime\", db: \"@db.Timestamptz\" },\n\tjsonb: { type: \"Json\" },\n};\n\nfunction generatePrisma(\n\tentries: Array<[string, Record<string, IndexColumn>]>,\n\tschemaName: string | undefined,\n\tdatasourceEnv: string,\n): string {\n\tconst pks = SOURCE_READ_PKS as Record<string, IndexPrimaryKey>;\n\tconst skipped: string[] = [];\n\n\tconst models: string[] = [];\n\tfor (const [table, cols] of entries) {\n\t\tconst pk = pks[table];\n\t\tif (!pk) {\n\t\t\tskipped.push(table);\n\t\t\tcontinue;\n\t\t}\n\t\tconst single = pk.length === 1 ? pk[0] : null;\n\t\tconst lines = Object.entries(cols).map(([col, def]) => {\n\t\t\tconst field = snakeToCamel(col);\n\t\t\tconst mapped = PRISMA_TYPE[def.type];\n\t\t\tconst attrs: string[] = [];\n\t\t\tif (single === col) attrs.push(\"@id\");\n\t\t\tif (field !== col) attrs.push(`@map(\"${col}\")`);\n\t\t\tif (mapped.db) attrs.push(mapped.db);\n\t\t\tconst optional = def.nullable ? \"?\" : \"\";\n\t\t\treturn ` ${field} ${mapped.type}${optional}${attrs.length ? ` ${attrs.join(\" \")}` : \"\"}`;\n\t\t});\n\t\tconst block: string[] = [];\n\t\tif (!single) {\n\t\t\tblock.push(` @@id([${pk.map(snakeToCamel).join(\", \")}])`);\n\t\t}\n\t\tblock.push(` @@map(\"${table}\")`);\n\t\tif (schemaName) block.push(` @@schema(\"${schemaName}\")`);\n\t\tmodels.push(\n\t\t\t`model ${pascalCase(table)} {\\n${lines.join(\"\\n\")}\\n\\n${block.join(\"\\n\")}\\n}`,\n\t\t);\n\t}\n\n\tconst header = [\n\t\t\"// Generated by `sl codegen index --target prisma`. Do not edit by hand.\",\n\t\t...(skipped.length\n\t\t\t? [\n\t\t\t\t\t`// Omitted (no read-set primary key, query via another target): ${skipped.join(\", \")}`,\n\t\t\t\t]\n\t\t\t: []),\n\t\t\"\",\n\t\t\"datasource db {\",\n\t\t' provider = \"postgresql\"',\n\t\t` url = env(\"${datasourceEnv}\")`,\n\t\t...(schemaName ? [` schemas = [\"${schemaName}\"]`] : []),\n\t\t\"}\",\n\t\t\"\",\n\t\t\"generator client {\",\n\t\t' provider = \"prisma-client-js\"',\n\t\t...(schemaName ? [' previewFeatures = [\"multiSchema\"]'] : []),\n\t\t\"}\",\n\t].join(\"\\n\");\n\n\treturn `${header}\\n\\n${models.join(\"\\n\\n\")}\\n`;\n}\n\nexport function generateIndexSchema(\n\ttarget: IndexCodegenTarget,\n\topts: IndexCodegenOptions = {},\n): string {\n\tconst entries = selectTables(opts);\n\tswitch (target) {\n\t\tcase \"prisma\":\n\t\t\treturn generatePrisma(\n\t\t\t\tentries,\n\t\t\t\topts.schemaName,\n\t\t\t\topts.datasourceEnv ?? \"DATABASE_URL\",\n\t\t\t);\n\t\tcase \"kysely\":\n\t\t\treturn generateKysely(entries, opts.schemaName);\n\t\tcase \"drizzle\":\n\t\t\treturn generateDrizzle(entries, opts.schemaName);\n\t\tcase \"json-schema\":\n\t\t\treturn generateJsonSchema(entries);\n\t\tdefault:\n\t\t\tthrow new Error(`Unsupported Index codegen target \"${target}\".`);\n\t}\n}\n\n/** Index tables available to codegen, for help text and validation. */\nexport const INDEX_CODEGEN_TABLES: readonly string[] = Object.keys(\n\tSOURCE_READ_TYPES,\n) as Array<keyof IndexTables>;\n",
15
15
  "import type { Database } from \"@secondlayer/shared/db\";\nimport { type Kysely, sql } from \"kysely\";\nimport type {\n\tSubgraphDefinition,\n\tSubgraphSchema,\n\tSubgraphTable,\n} from \"../types.ts\";\nimport { validateSubgraphDefinition } from \"../validate.ts\";\nimport {\n\tTYPE_MAP,\n\temitIndexedColumnIndexDDL,\n\temitTableDDL,\n\tgenerateSubgraphSQL,\n\ttableNeedsTrgm,\n} from \"./generator.ts\";\nimport { pgSchemaName } from \"./utils.ts\";\n\ntype AnyDb = Kysely<Database>;\n\n/** Deep-clone an object, converting BigInts to strings for JSON serialization. */\nfunction toJsonSafe(obj: unknown): unknown {\n\treturn JSON.parse(\n\t\tJSON.stringify(obj, (_key, value) =>\n\t\t\ttypeof value === \"bigint\" ? value.toString() : value,\n\t\t),\n\t);\n}\n\nexport interface TableDiff {\n\t/** Tables added to the schema */\n\taddedTables: string[];\n\t/** Tables removed from the schema */\n\tremovedTables: string[];\n\t/** Per-table column diffs (only for tables present in both) */\n\ttables: Record<string, ColumnDiff>;\n\t/** Per-table uniqueKeys / composite-index diffs (tables present in both) */\n\tconstraints: Record<string, ConstraintDiff>;\n}\n\nexport interface ColumnDiff {\n\tadded: string[];\n\tremoved: string[];\n\t/** Data-shape changes (type/nullable/default) — breaking. */\n\tchanged: string[];\n\t/** Only the `indexed`/`search` flags differ — index DDL, never a rebuild. */\n\tindexChanged: string[];\n}\n\nexport interface ConstraintDiff {\n\t/** New uniqueKeys entries → `ALTER TABLE … ADD CONSTRAINT` (additive). */\n\taddedUniqueKeys: string[][];\n\t/** Removed uniqueKeys entries — breaking: handler upserts may target them. */\n\tremovedUniqueKeys: string[][];\n\t/** New composite `indexes` entries → CREATE INDEX (additive). */\n\taddedIndexes: string[][];\n\t/** Removed composite `indexes` entries → DROP INDEX (additive). */\n\tremovedIndexes: string[][];\n}\n\n/** Column identity minus the index flags: what actually shapes stored data. */\nfunction columnDataShape(col: unknown): string {\n\tconst { indexed, search, ...rest } = (col ?? {}) as Record<string, unknown>;\n\treturn JSON.stringify(rest, Object.keys(rest).sort());\n}\n\n/** Diff two `string[][]` lists (uniqueKeys / indexes) by column-list value. */\nfunction diffKeyLists(\n\texisting: readonly (readonly string[])[] | undefined,\n\tincoming: readonly (readonly string[])[] | undefined,\n): { added: string[][]; removed: string[][] } {\n\tconst key = (cols: readonly string[]) => JSON.stringify(cols);\n\tconst existingSet = new Set((existing ?? []).map(key));\n\tconst incomingSet = new Set((incoming ?? []).map(key));\n\treturn {\n\t\tadded: (incoming ?? [])\n\t\t\t.filter((cols) => !existingSet.has(key(cols)))\n\t\t\t.map((cols) => [...cols]),\n\t\tremoved: (existing ?? [])\n\t\t\t.filter((cols) => !incomingSet.has(key(cols)))\n\t\t\t.map((cols) => [...cols]),\n\t};\n}\n\n/**\n * Compare two multi-table subgraph schemas and return differences.\n *\n * Index-only changes (`indexed`/`search` flags, composite `indexes`) are\n * reported separately from data-shape changes: an index never requires a\n * rebuild, so it must never land in `changed`/breaking — flipping\n * `indexed: true` on a populated column used to cost a full reindex.\n */\nexport function diffSchema(\n\texisting: SubgraphSchema,\n\tincoming: SubgraphSchema,\n): TableDiff {\n\tconst existingTables = new Set(Object.keys(existing));\n\tconst incomingTables = new Set(Object.keys(incoming));\n\n\tconst addedTables = [...incomingTables].filter((t) => !existingTables.has(t));\n\tconst removedTables = [...existingTables].filter(\n\t\t(t) => !incomingTables.has(t),\n\t);\n\n\tconst tables: Record<string, ColumnDiff> = {};\n\tconst constraints: Record<string, ConstraintDiff> = {};\n\tfor (const tableName of incomingTables) {\n\t\tif (!existingTables.has(tableName)) continue;\n\t\tconst existingTable = existing[tableName];\n\t\tconst incomingTable = incoming[tableName];\n\t\tconst existingCols = existingTable?.columns;\n\t\tconst incomingCols = incomingTable?.columns;\n\n\t\tconst existingKeys = new Set(Object.keys(existingCols));\n\t\tconst incomingKeys = new Set(Object.keys(incomingCols));\n\t\tconst common = [...incomingKeys].filter((k) => existingKeys.has(k));\n\n\t\tconst sortedStringify = (o: unknown) =>\n\t\t\tJSON.stringify(o, Object.keys(o as object).sort());\n\n\t\ttables[tableName] = {\n\t\t\tadded: [...incomingKeys].filter((k) => !existingKeys.has(k)),\n\t\t\tremoved: [...existingKeys].filter((k) => !incomingKeys.has(k)),\n\t\t\tchanged: common.filter(\n\t\t\t\t(k) =>\n\t\t\t\t\tcolumnDataShape(existingCols[k]) !== columnDataShape(incomingCols[k]),\n\t\t\t),\n\t\t\tindexChanged: common.filter(\n\t\t\t\t(k) =>\n\t\t\t\t\tcolumnDataShape(existingCols[k]) ===\n\t\t\t\t\t\tcolumnDataShape(incomingCols[k]) &&\n\t\t\t\t\tsortedStringify(existingCols[k]) !== sortedStringify(incomingCols[k]),\n\t\t\t),\n\t\t};\n\n\t\tconst uq = diffKeyLists(\n\t\t\texistingTable?.uniqueKeys,\n\t\t\tincomingTable?.uniqueKeys,\n\t\t);\n\t\tconst ix = diffKeyLists(existingTable?.indexes, incomingTable?.indexes);\n\t\tconstraints[tableName] = {\n\t\t\taddedUniqueKeys: uq.added,\n\t\t\tremovedUniqueKeys: uq.removed,\n\t\t\taddedIndexes: ix.added,\n\t\t\tremovedIndexes: ix.removed,\n\t\t};\n\t}\n\n\treturn { addedTables, removedTables, tables, constraints };\n}\n\n/**\n * Returns true if the diff contains any breaking changes\n * (removed tables, removed columns, changed column types, or removed\n * uniqueKeys — handler upserts `ON CONFLICT` against those).\n * Index-flag and composite-index changes are never breaking.\n */\nexport function hasBreakingChanges(diff: TableDiff): {\n\tbreaking: boolean;\n\treasons: string[];\n} {\n\tconst reasons: string[] = [];\n\tif (diff.removedTables.length > 0) {\n\t\treasons.push(`removed tables: [${diff.removedTables.join(\", \")}]`);\n\t}\n\tfor (const [table, colDiff] of Object.entries(diff.tables)) {\n\t\tif (colDiff.removed.length > 0) {\n\t\t\treasons.push(`${table}: removed columns [${colDiff.removed.join(\", \")}]`);\n\t\t}\n\t\tif (colDiff.changed.length > 0) {\n\t\t\treasons.push(`${table}: changed columns [${colDiff.changed.join(\", \")}]`);\n\t\t}\n\t}\n\tfor (const [table, conDiff] of Object.entries(diff.constraints ?? {})) {\n\t\tif (conDiff.removedUniqueKeys.length > 0) {\n\t\t\treasons.push(\n\t\t\t\t`${table}: removed uniqueKeys [${conDiff.removedUniqueKeys\n\t\t\t\t\t.map((cols) => `(${cols.join(\", \")})`)\n\t\t\t\t\t.join(\", \")}]`,\n\t\t\t);\n\t\t}\n\t}\n\treturn { breaking: reasons.length > 0, reasons };\n}\n\n/** Increment the patch segment of a semver string. \"1.0.2\" → \"1.0.3\" */\nfunction bumpPatch(version: string): string {\n\tconst parts = version.split(\".\");\n\tif (parts.length !== 3) return \"1.0.1\";\n\tconst patch = Number.parseInt(parts[2] ?? \"0\", 10);\n\treturn `${parts[0]}.${parts[1]}.${Number.isNaN(patch) ? 1 : patch + 1}`;\n}\n\nexport interface DeployDiff {\n\taddedTables: string[];\n\tremovedTables: string[];\n\taddedColumns: Record<string, string[]>;\n\tbreakingChanges: string[];\n\t/** Columns whose only change was the `indexed`/`search` flag (index DDL applied in place). */\n\tindexChanges?: Record<string, string[]>;\n\t/** uniqueKeys added in place via ALTER TABLE … ADD CONSTRAINT. */\n\taddedUniqueKeys?: Record<string, string[][]>;\n}\n\n/**\n * Map a raw `TableDiff` (+ breaking reasons) into the wire `DeployDiff`. `null`\n * diff (e.g. same-hash force reindex, where no schema diff exists) → empty\n * added/removed/columns with reasons preserved. Single source for both the\n * non-refuse \"reindexed\" result and the refuse-path error payload.\n */\nfunction toDeployDiff(diff: TableDiff | null, reasons: string[]): DeployDiff {\n\treturn {\n\t\taddedTables: diff?.addedTables ?? [],\n\t\tremovedTables: diff?.removedTables ?? [],\n\t\taddedColumns: diff\n\t\t\t? Object.fromEntries(\n\t\t\t\t\tObject.entries(diff.tables)\n\t\t\t\t\t\t.filter(([, c]) => c.added.length > 0)\n\t\t\t\t\t\t.map(([t, c]) => [t, c.added]),\n\t\t\t\t)\n\t\t\t: {},\n\t\tbreakingChanges: reasons,\n\t};\n}\n\nexport interface DeployPlan {\n\tschemaName: string;\n\t/** `DROP SCHEMA … CASCADE` a destructive rebuild would run first. */\n\tdropStatement: string;\n\t/** DDL the deploy will run. */\n\tstatements: string[];\n}\n\n/**\n * Render the DDL a deploy would run, without executing.\n * Powers `--dry-run`: the user reviews exactly what a deploy does first.\n */\nexport function renderDeployPlan(\n\tdef: SubgraphDefinition,\n\tschemaName?: string,\n): DeployPlan {\n\tvalidateSubgraphDefinition(def);\n\tconst { statements } = generateSubgraphSQL(def, schemaName);\n\tconst schema = schemaName ?? pgSchemaName(def.name);\n\tconst dropStatement = `DROP SCHEMA IF EXISTS \"${schema}\" CASCADE;`;\n\treturn { schemaName: schema, dropStatement, statements };\n}\n\n/**\n * Deploy a subgraph schema to the database.\n * - New subgraph → CREATE SCHEMA + tables + register\n * - Same hash → no-op (handler path updated)\n * - Additive change → ALTER TABLE ADD COLUMN / CREATE TABLE for new tables\n * - Breaking change → auto-reindex (drop + recreate)\n */\nexport async function deploySchema(\n\tdb: AnyDb,\n\tdef: SubgraphDefinition,\n\thandlerPath: string,\n\topts?: {\n\t\tforceReindex?: boolean;\n\t\tapiKeyId?: string;\n\t\taccountId?: string;\n\t\tschemaName?: string;\n\t\thandlerCode?: string;\n\t\tsourceCode?: string;\n\t},\n): Promise<{\n\taction: \"created\" | \"unchanged\" | \"handler_updated\" | \"updated\" | \"reindexed\";\n\tsubgraphId: string;\n\tversion: string;\n\tdiff?: DeployDiff;\n\t/** Non-fatal caveats the caller should surface (e.g. handler grafts at tip). */\n\twarnings?: string[];\n}> {\n\tvalidateSubgraphDefinition(def);\n\n\tconst { statements, hash } = generateSubgraphSQL(def, opts?.schemaName);\n\tconst { getSubgraph, registerSubgraph } = await import(\n\t\t\"@secondlayer/shared/db/queries/subgraphs\"\n\t);\n\n\tconst ddlDb = db;\n\n\tconst existing = await getSubgraph(db, def.name, opts?.accountId);\n\n\tconst schemaName = opts?.schemaName ?? pgSchemaName(def.name);\n\n\t// Server owns versioning: bump patch from existing, or start at 1.0.0\n\tconst newVersion = existing ? bumpPatch(existing.version) : \"1.0.0\";\n\n\tconst regData = {\n\t\tname: def.name,\n\t\tversion: newVersion,\n\t\tdefinition: toJsonSafe({\n\t\t\tname: def.name,\n\t\t\tdescription: def.description,\n\t\t\tstartBlock: def.startBlock,\n\t\t\tsources: def.sources,\n\t\t\tschema: def.schema,\n\t\t}) as Record<string, unknown>,\n\t\tschemaHash: hash,\n\t\thandlerPath,\n\t\tapiKeyId: opts?.apiKeyId,\n\t\taccountId: opts?.accountId,\n\t\thandlerCode: opts?.handlerCode,\n\t\tsourceCode: opts?.sourceCode,\n\t\tschemaName,\n\t\tstartBlock: def.startBlock,\n\t};\n\n\tif (existing) {\n\t\t// Guard against zombie rows: registry entry exists but PG schema was dropped\n\t\t// (e.g. partial delete or manual cleanup). Treat as a new subgraph.\n\t\tconst schemaExists = await sql<{ exists: boolean }>`\n\t\t\tSELECT EXISTS (\n\t\t\t\tSELECT 1 FROM information_schema.schemata\n\t\t\t\tWHERE schema_name = ${schemaName}\n\t\t\t) AS \"exists\"\n\t\t`\n\t\t\t.execute(ddlDb)\n\t\t\t.then((r) => r.rows[0]?.exists ?? false);\n\n\t\tif (!schemaExists) {\n\t\t\tfor (const stmt of statements) {\n\t\t\t\tawait sql.raw(stmt).execute(ddlDb);\n\t\t\t}\n\t\t\tconst sg = await registerSubgraph(db, regData);\n\t\t\treturn { action: \"reindexed\", subgraphId: sg.id, version: newVersion };\n\t\t}\n\n\t\tif (existing.schema_hash === hash && !opts?.forceReindex) {\n\t\t\t// Update handler path and code in case file moved or handler changed.\n\t\t\tconst handlerChanged =\n\t\t\t\topts?.handlerCode != null && opts.handlerCode !== existing.handler_code;\n\t\t\tconst { updateSubgraphHandlerPath } = await import(\n\t\t\t\t\"@secondlayer/shared/db/queries/subgraphs\"\n\t\t\t);\n\t\t\tawait updateSubgraphHandlerPath(db, def.name, handlerPath, {\n\t\t\t\thandlerCode: opts?.handlerCode,\n\t\t\t\tsourceCode: opts?.sourceCode,\n\t\t\t});\n\t\t\treturn {\n\t\t\t\taction: handlerChanged ? \"handler_updated\" : \"unchanged\",\n\t\t\t\tsubgraphId: existing.id,\n\t\t\t\tversion: existing.version,\n\t\t\t\t// A handler-only change takes effect from the CURRENT tip: rows\n\t\t\t\t// already indexed keep the old handler's output. Silent until now —\n\t\t\t\t// say it, and name the fix.\n\t\t\t\t...(handlerChanged\n\t\t\t\t\t? {\n\t\t\t\t\t\t\twarnings: [\n\t\t\t\t\t\t\t\t\"handler changed but schema did not: new logic applies from the current tip only. \" +\n\t\t\t\t\t\t\t\t\t\"Rows already indexed were computed by the previous handler. \" +\n\t\t\t\t\t\t\t\t\t\"To recompute history, redeploy with --reindex (or backfill a range).\",\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t}\n\t\t\t\t\t: {}),\n\t\t\t};\n\t\t}\n\n\t\tif (existing.schema_hash === hash && opts?.forceReindex) {\n\t\t\t// Same schema but force reindex requested — drop and recreate.\n\t\t\tawait sql\n\t\t\t\t.raw(`DROP SCHEMA IF EXISTS \"${schemaName}\" CASCADE`)\n\t\t\t\t.execute(ddlDb);\n\t\t\tfor (const stmt of statements) {\n\t\t\t\tawait sql.raw(stmt).execute(ddlDb);\n\t\t\t}\n\t\t\tconst sg = await registerSubgraph(db, regData);\n\t\t\treturn { action: \"reindexed\", subgraphId: sg.id, version: newVersion };\n\t\t}\n\n\t\tif (existing.definition.schema) {\n\t\t\tconst diff = diffSchema(\n\t\t\t\texisting.definition.schema as SubgraphSchema,\n\t\t\t\tdef.schema,\n\t\t\t);\n\t\t\tconst { breaking, reasons } = hasBreakingChanges(diff);\n\n\t\t\tif (breaking || opts?.forceReindex) {\n\t\t\t\t// Breaking change or forced: drop schema, recreate, register\n\t\t\t\tawait sql\n\t\t\t\t\t.raw(`DROP SCHEMA IF EXISTS \"${schemaName}\" CASCADE`)\n\t\t\t\t\t.execute(ddlDb);\n\t\t\t\tfor (const stmt of statements) {\n\t\t\t\t\tawait sql.raw(stmt).execute(ddlDb);\n\t\t\t\t}\n\t\t\t\tconst sg = await registerSubgraph(db, regData);\n\t\t\t\tconst deployDiff = toDeployDiff(diff, reasons);\n\t\t\t\treturn {\n\t\t\t\t\taction: \"reindexed\",\n\t\t\t\t\tsubgraphId: sg.id,\n\t\t\t\t\tversion: newVersion,\n\t\t\t\t\tdiff: deployDiff,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Create new tables using the SAME per-table emitter as the full\n\t\t\t// generator, so an additively-created table gets its UNIQUE constraints,\n\t\t\t// composite indexes, column defaults, and FKs — not just the bare columns.\n\t\t\t// (A missing UNIQUE here previously made a handler upsert ON CONFLICT fail\n\t\t\t// at runtime on additively-added tables.)\n\t\t\tconst addedDefs = diff.addedTables\n\t\t\t\t.map((tableName) => ({ tableName, tableDef: def.schema[tableName] }))\n\t\t\t\t.filter(\n\t\t\t\t\t(t): t is { tableName: string; tableDef: SubgraphTable } =>\n\t\t\t\t\t\tt.tableDef !== undefined,\n\t\t\t\t);\n\n\t\t\t// pg_trgm must exist before any search-column GIN index on the new tables.\n\t\t\tif (addedDefs.some(({ tableDef }) => tableNeedsTrgm(tableDef))) {\n\t\t\t\tawait sql.raw(\"CREATE EXTENSION IF NOT EXISTS pg_trgm\").execute(ddlDb);\n\t\t\t}\n\t\t\tfor (const { tableName, tableDef } of addedDefs) {\n\t\t\t\tfor (const stmt of emitTableDDL(schemaName, tableName, tableDef)) {\n\t\t\t\t\tawait sql.raw(stmt).execute(ddlDb);\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Add columns to existing tables\n\t\t\tfor (const [tableName, colDiff] of Object.entries(diff.tables)) {\n\t\t\t\tif (colDiff.added.length === 0) continue;\n\t\t\t\tconst qualifiedName = `${schemaName}.${tableName}`;\n\t\t\t\tconst tableDef = def.schema[tableName];\n\t\t\t\tif (!tableDef) continue;\n\t\t\t\tfor (const colName of colDiff.added) {\n\t\t\t\t\tconst col = tableDef.columns[colName];\n\t\t\t\t\tif (!col) continue;\n\t\t\t\t\tconst sqlType = TYPE_MAP[col.type];\n\t\t\t\t\tif (!sqlType) continue;\n\t\t\t\t\tconst nullable = col.nullable\n\t\t\t\t\t\t? \"\"\n\t\t\t\t\t\t: ` NOT NULL DEFAULT ${getDefault(col.type)}`;\n\t\t\t\t\tawait sql\n\t\t\t\t\t\t.raw(\n\t\t\t\t\t\t\t`ALTER TABLE ${qualifiedName} ADD COLUMN ${colName} ${sqlType}${nullable}`,\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.execute(ddlDb);\n\t\t\t\t\tif (col.indexed) {\n\t\t\t\t\t\tfor (const stmt of emitIndexedColumnIndexDDL(\n\t\t\t\t\t\t\tschemaName,\n\t\t\t\t\t\t\ttableName,\n\t\t\t\t\t\t\tcolName,\n\t\t\t\t\t\t).create) {\n\t\t\t\t\t\t\tawait sql.raw(stmt).execute(ddlDb);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif (col.search) {\n\t\t\t\t\t\tawait sql\n\t\t\t\t\t\t\t.raw(\n\t\t\t\t\t\t\t\t`CREATE INDEX IF NOT EXISTS idx_${schemaName}_${tableName}_${colName}_trgm ON ${qualifiedName} USING gin (${colName} gin_trgm_ops)`,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t.execute(ddlDb);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Index-flag flips on EXISTING (populated) columns: pure index DDL,\n\t\t\t// applied in place. CONCURRENTLY because the table has data and this\n\t\t\t// runs outside a transaction (autocommit per statement) — a plain\n\t\t\t// CREATE INDEX would take a write lock for the whole build.\n\t\t\tconst existingSchema = existing.definition.schema as SubgraphSchema;\n\t\t\tconst flagFlips: Record<string, string[]> = {};\n\t\t\tconst needsTrgmForFlips = Object.entries(diff.tables).some(\n\t\t\t\t([tableName, colDiff]) =>\n\t\t\t\t\tcolDiff.indexChanged.some(\n\t\t\t\t\t\t(colName) =>\n\t\t\t\t\t\t\tdef.schema[tableName]?.columns[colName]?.search &&\n\t\t\t\t\t\t\t!existingSchema[tableName]?.columns[colName]?.search,\n\t\t\t\t\t),\n\t\t\t);\n\t\t\tif (needsTrgmForFlips) {\n\t\t\t\tawait sql.raw(\"CREATE EXTENSION IF NOT EXISTS pg_trgm\").execute(ddlDb);\n\t\t\t}\n\t\t\tfor (const [tableName, colDiff] of Object.entries(diff.tables)) {\n\t\t\t\tif (colDiff.indexChanged.length === 0) continue;\n\t\t\t\tflagFlips[tableName] = colDiff.indexChanged;\n\t\t\t\tconst qualifiedName = `${schemaName}.${tableName}`;\n\t\t\t\tfor (const colName of colDiff.indexChanged) {\n\t\t\t\t\tconst was = existingSchema[tableName]?.columns[colName];\n\t\t\t\t\tconst now = def.schema[tableName]?.columns[colName];\n\t\t\t\t\tif (!now) continue;\n\t\t\t\t\tconst trgmIdx = `idx_${schemaName}_${tableName}_${colName}_trgm`;\n\t\t\t\t\tconst indexedIdx = emitIndexedColumnIndexDDL(\n\t\t\t\t\t\tschemaName,\n\t\t\t\t\t\ttableName,\n\t\t\t\t\t\tcolName,\n\t\t\t\t\t\t{ concurrently: true },\n\t\t\t\t\t);\n\t\t\t\t\tif (now.indexed && !was?.indexed) {\n\t\t\t\t\t\tfor (const stmt of indexedIdx.create) {\n\t\t\t\t\t\t\tawait sql.raw(stmt).execute(ddlDb);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (!now.indexed && was?.indexed) {\n\t\t\t\t\t\tfor (const stmt of indexedIdx.drop) {\n\t\t\t\t\t\t\tawait sql.raw(stmt).execute(ddlDb);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif (now.search && !was?.search) {\n\t\t\t\t\t\tawait sql\n\t\t\t\t\t\t\t.raw(\n\t\t\t\t\t\t\t\t`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${trgmIdx} ON ${qualifiedName} USING gin (${colName} gin_trgm_ops)`,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t.execute(ddlDb);\n\t\t\t\t\t} else if (!now.search && was?.search) {\n\t\t\t\t\t\tawait sql\n\t\t\t\t\t\t\t.raw(`DROP INDEX CONCURRENTLY IF EXISTS ${schemaName}.${trgmIdx}`)\n\t\t\t\t\t\t\t.execute(ddlDb);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// uniqueKeys additions: ADD CONSTRAINT in place (idempotent via\n\t\t\t// pg_constraint check — Postgres has no ADD CONSTRAINT IF NOT EXISTS).\n\t\t\t// Removals never reach here: hasBreakingChanges classifies them\n\t\t\t// breaking, which lands in the reindex branch above.\n\t\t\tconst addedUq: Record<string, string[][]> = {};\n\t\t\tfor (const [tableName, conDiff] of Object.entries(diff.constraints)) {\n\t\t\t\tconst qualifiedName = `${schemaName}.${tableName}`;\n\t\t\t\tfor (const cols of conDiff.addedUniqueKeys) {\n\t\t\t\t\tconst constraintName = `uq_${schemaName}_${tableName}_${cols.join(\"_\")}`;\n\t\t\t\t\tconst exists = await sql<{ exists: boolean }>`\n\t\t\t\t\t\tSELECT EXISTS (\n\t\t\t\t\t\t\tSELECT 1 FROM pg_constraint\n\t\t\t\t\t\t\tWHERE conname = ${constraintName}\n\t\t\t\t\t\t) AS \"exists\"\n\t\t\t\t\t`\n\t\t\t\t\t\t.execute(ddlDb)\n\t\t\t\t\t\t.then((r) => r.rows[0]?.exists ?? false);\n\t\t\t\t\tif (!exists) {\n\t\t\t\t\t\tawait sql\n\t\t\t\t\t\t\t.raw(\n\t\t\t\t\t\t\t\t`ALTER TABLE ${qualifiedName} ADD CONSTRAINT ${constraintName} UNIQUE (${cols.join(\", \")})`,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t.execute(ddlDb);\n\t\t\t\t\t}\n\t\t\t\t\taddedUq[tableName] = [...(addedUq[tableName] ?? []), cols];\n\t\t\t\t}\n\t\t\t\t// Composite indexes: additive both ways. Names are positional in the\n\t\t\t\t// incoming schema; a reordered (unchanged-value) entry keeps its old\n\t\t\t\t// positionally-derived name, which is harmless.\n\t\t\t\tfor (const cols of conDiff.addedIndexes) {\n\t\t\t\t\tconst i = (def.schema[tableName]?.indexes ?? []).findIndex(\n\t\t\t\t\t\t(entry) => JSON.stringify(entry) === JSON.stringify(cols),\n\t\t\t\t\t);\n\t\t\t\t\tconst idxName = `idx_${schemaName}_${tableName}_composite_${i}`;\n\t\t\t\t\tawait sql\n\t\t\t\t\t\t.raw(\n\t\t\t\t\t\t\t`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${idxName} ON ${qualifiedName} (${cols.join(\", \")})`,\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.execute(ddlDb);\n\t\t\t\t}\n\t\t\t\tfor (const cols of conDiff.removedIndexes) {\n\t\t\t\t\tconst i = (existingSchema[tableName]?.indexes ?? []).findIndex(\n\t\t\t\t\t\t(entry) => JSON.stringify(entry) === JSON.stringify(cols),\n\t\t\t\t\t);\n\t\t\t\t\tif (i < 0) continue;\n\t\t\t\t\tconst idxName = `idx_${schemaName}_${tableName}_composite_${i}`;\n\t\t\t\t\tawait sql\n\t\t\t\t\t\t.raw(`DROP INDEX CONCURRENTLY IF EXISTS ${schemaName}.${idxName}`)\n\t\t\t\t\t\t.execute(ddlDb);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst sg = await registerSubgraph(db, regData);\n\t\t\tconst addedCols: Record<string, string[]> = {};\n\t\t\tfor (const [t, colDiff] of Object.entries(diff.tables)) {\n\t\t\t\tif ((colDiff as ColumnDiff).added.length > 0)\n\t\t\t\t\taddedCols[t] = (colDiff as ColumnDiff).added;\n\t\t\t}\n\t\t\tconst deployDiff: DeployDiff = {\n\t\t\t\taddedTables: diff.addedTables,\n\t\t\t\tremovedTables: [],\n\t\t\t\taddedColumns: addedCols,\n\t\t\t\tbreakingChanges: [],\n\t\t\t\t...(Object.keys(flagFlips).length > 0\n\t\t\t\t\t? { indexChanges: flagFlips }\n\t\t\t\t\t: {}),\n\t\t\t\t...(Object.keys(addedUq).length > 0\n\t\t\t\t\t? { addedUniqueKeys: addedUq }\n\t\t\t\t\t: {}),\n\t\t\t};\n\t\t\treturn {\n\t\t\t\taction: \"updated\",\n\t\t\t\tsubgraphId: sg.id,\n\t\t\t\tversion: newVersion,\n\t\t\t\tdiff: deployDiff,\n\t\t\t};\n\t\t}\n\t}\n\n\t// New subgraph — execute all DDL\n\tfor (const stmt of statements) {\n\t\tawait sql.raw(stmt).execute(ddlDb);\n\t}\n\n\tconst sg = await registerSubgraph(db, regData);\n\treturn { action: \"created\", subgraphId: sg.id, version: newVersion };\n}\n\nfunction getDefault(type: string): string {\n\tswitch (type) {\n\t\tcase \"text\":\n\t\tcase \"principal\":\n\t\t\treturn \"''\";\n\t\tcase \"uint\":\n\t\tcase \"int\":\n\t\t\treturn \"0\";\n\t\tcase \"boolean\":\n\t\t\treturn \"false\";\n\t\tcase \"timestamp\":\n\t\t\treturn \"NOW()\";\n\t\tcase \"jsonb\":\n\t\t\treturn \"'{}'\";\n\t\tdefault:\n\t\t\treturn \"''\";\n\t}\n}\n",
16
- "import { getSourceDb } from \"@secondlayer/shared/db\";\nimport type { Transaction } from \"@secondlayer/shared/db\";\nimport {\n\ttype IndexEventRow,\n\ttype IndexHttpClient,\n\ttype IndexTransactionRow,\n\tcreateInternalIndexHttpClient,\n} from \"@secondlayer/shared/index-http\";\nimport { logger } from \"@secondlayer/shared/logger\";\nimport type { SubgraphDefinition, SubgraphFilter } from \"../types.ts\";\nimport { type BlockData, loadBlockRange } from \"./batch-loader.ts\";\nimport { ObserverHttpBlockSource } from \"./observer-http-source.ts\";\nimport {\n\treconstructBlock,\n\treconstructEvent,\n\treconstructTransaction,\n\treconstructTxFromEventRow,\n} from \"./reconstruct.ts\";\n\n/**\n * Where the subgraph runtime reads canonical chain data. Today it taps the\n * indexer Postgres directly (`PostgresBlockSource`); the re-point adds a\n * `PublicApiBlockSource` that consumes the Streams clock + Index data over\n * HTTP. `matchSources` / handlers / flush / outbox are unchanged — only the\n * loader + tip swap behind this seam.\n */\nexport interface BlockSource {\n\t/**\n\t * Highest canonical block height available to process.\n\t *\n\t * `opts` is an HTTP-plane long-poll hint (plan-063 3.4/3.5), ignored by any\n\t * source with no such notion (the Postgres tap: a local DB read is already\n\t * instant, nothing to wait on). `knownHeight` is the last tip THIS caller\n\t * observed; passing `wait` without it is a no-op — the server needs a\n\t * baseline to decide whether anything has changed.\n\t */\n\tgetTip(opts?: { wait?: number; knownHeight?: number }): Promise<number>;\n\t/** Canonical block data for [fromHeight, toHeight], keyed by height. */\n\tloadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>>;\n\t/** Sparse-scan probe: lowest height in (afterHeight, untilHeight] holding\n\t * an event this source's subgraph could match, or null when the rest of\n\t * the range is empty. Optional — only event-scoped sources support it. */\n\tnextDataHeight?(\n\t\tafterHeight: number,\n\t\tuntilHeight: number,\n\t): Promise<number | null>;\n\t/**\n\t * Per-decoder committed heights from the SAME response `getTip()` just\n\t * fetched (no extra request) — undefined for a source with no such notion\n\t * (the Postgres tap: local mode reads `decoder_checkpoints` directly and\n\t * never needs this). Lets a caller (the chain evaluator) narrow its own\n\t * bound to the decoders it actually reads instead of `getTip()`'s\n\t * conservative cross-decoder floor. Call AFTER `getTip()`.\n\t */\n\tgetDecodedHeights?(): Record<string, number | null> | undefined;\n}\n\n/** A (decoded event type, optional contract scope) pair the sparse probe\n * checks. Contract scoping is what makes token-subgraph reindexes leap over\n * everything that isn't their token. */\nexport type SparseProbeTarget = { eventType: string; contractId?: string };\n\n/** Sparse scanning is sound only when EVERY source is an event-type filter —\n * a contract_call/contract_deploy source matches transactions, which the\n * event probe can't see. */\nexport function canSparseScan(subgraph: SubgraphDefinition): boolean {\n\tif (Array.isArray(subgraph.sources)) return false;\n\tconst filters = sourceFilters(subgraph);\n\tif (filters.length === 0) return false;\n\treturn filters.every((f) => Boolean(EVENT_FILTER_TO_INDEX_TYPE[f.type]));\n}\n\n/** Probe targets for a subgraph's filters: decoded type + contract scope when\n * the filter pins one (assetIdentifier \"SP….contract::asset\" or contractId). */\nexport function sparseProbeTargets(\n\tsubgraph: SubgraphDefinition,\n): SparseProbeTarget[] {\n\tconst targets = new Map<string, SparseProbeTarget>();\n\tfor (const f of sourceFilters(subgraph)) {\n\t\tconst eventType = EVENT_FILTER_TO_INDEX_TYPE[f.type];\n\t\tif (!eventType) continue;\n\t\tconst scoped = f as { assetIdentifier?: string; contractId?: string };\n\t\tconst contractId =\n\t\t\tscoped.contractId ?? scoped.assetIdentifier?.split(\"::\")[0];\n\t\tconst key = `${eventType}|${contractId ?? \"\"}`;\n\t\ttargets.set(key, { eventType, ...(contractId ? { contractId } : {}) });\n\t}\n\treturn [...targets.values()];\n}\n\n/** Reads directly from the shared indexer Postgres (the original behavior). */\nexport class PostgresBlockSource implements BlockSource {\n\tasync getTip(): Promise<number> {\n\t\tconst progress = await getSourceDb()\n\t\t\t.selectFrom(\"index_progress\")\n\t\t\t.selectAll()\n\t\t\t.where(\"network\", \"=\", process.env.NETWORK ?? \"mainnet\")\n\t\t\t.executeTakeFirst();\n\t\treturn progress ? Number(progress.highest_seen_block) : 0;\n\t}\n\n\tloadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\treturn loadBlockRange(getSourceDb(), fromHeight, toHeight);\n\t}\n}\n\n// Subgraph source filter types that map to a decoded Index event_type. The\n// `_event` suffix is the runtime's raw form; print is keyed `print_event`.\nconst EVENT_FILTER_TO_INDEX_TYPE: Record<string, string> = {\n\tstx_transfer: \"stx_transfer\",\n\tstx_mint: \"stx_mint\",\n\tstx_burn: \"stx_burn\",\n\tstx_lock: \"stx_lock\",\n\tft_transfer: \"ft_transfer\",\n\tft_mint: \"ft_mint\",\n\tft_burn: \"ft_burn\",\n\tnft_transfer: \"nft_transfer\",\n\tnft_mint: \"nft_mint\",\n\tnft_burn: \"nft_burn\",\n\tprint_event: \"print\",\n\tnested_contract_call: \"nested_contract_call\",\n\tvar_set: \"var_set\",\n\tmap_set: \"map_set\",\n\tmap_insert: \"map_insert\",\n\tmap_delete: \"map_delete\",\n};\n\n// Tx-level source types — matched against /v1/index/transactions, not events.\nconst TX_SOURCE_TYPES = new Set([\"contract_call\", \"contract_deploy\"]);\n// Second clock (ordinal). Never part of a tx's classic event set: a\n// contract_call/contract_deploy source fetches every CLASSIC type, and vm\n// types are fetched only when a vm source names them.\nexport const VM_INDEX_EVENT_TYPES: ReadonlySet<string> = new Set([\n\t\"nested_contract_call\",\n\t\"var_set\",\n\t\"map_set\",\n\t\"map_insert\",\n\t\"map_delete\",\n]);\nconst CLASSIC_INDEX_EVENT_TYPES = [\n\t...new Set(Object.values(EVENT_FILTER_TO_INDEX_TYPE)),\n].filter((t) => !VM_INDEX_EVENT_TYPES.has(t));\n\nfunction sourceFilters(subgraph: SubgraphDefinition): SubgraphFilter[] {\n\tconst sources = subgraph.sources;\n\treturn Array.isArray(sources)\n\t\t? (sources as SubgraphFilter[])\n\t\t: Object.values(sources as Record<string, SubgraphFilter>);\n}\n\n/**\n * True when any source matches transactions (contract_call / contract_deploy):\n * the handler receives the full tx, so the loader must fetch real transactions.\n * Event-only subgraphs skip walkTransactions and synthesize the tx from joined\n * event context instead (the ~37x reindex over-fetch; see indexing-speed plan).\n */\nexport function needsTransactionData(subgraph: SubgraphDefinition): boolean {\n\treturn sourceFilters(subgraph).some((f) => TX_SOURCE_TYPES.has(f.type));\n}\n\n/** Build the (few) event-bearing txs from joined event context, one per tx_id —\n * the event-only replacement for draining every transaction in the range. */\nfunction synthesizeTxsFromEvents(events: IndexEventRow[]): Transaction[] {\n\tconst byId = new Map<string, Transaction>();\n\tfor (const e of events) {\n\t\tif (!byId.has(e.tx_id)) byId.set(e.tx_id, reconstructTxFromEventRow(e));\n\t}\n\treturn [...byId.values()];\n}\n\n/**\n * The Index event_types the loader must fetch for a set of source filter types.\n * A contract_call/contract_deploy source matches a tx and hands its FULL event\n * set to the handler, so when one is present we fetch every classic event type\n * (the matched tx's events must be complete); otherwise just the referenced\n * types. VM types ride a second clock and are fetched only when referenced.\n * Shared by the subgraph loader and the chain-trigger evaluator.\n */\nexport function indexEventTypesForFilterTypes(filterTypes: string[]): string[] {\n\tconst types = new Set<string>();\n\tif (filterTypes.some((t) => TX_SOURCE_TYPES.has(t))) {\n\t\tfor (const t of CLASSIC_INDEX_EVENT_TYPES) types.add(t);\n\t}\n\tfor (const t of filterTypes) {\n\t\tconst indexType = EVENT_FILTER_TO_INDEX_TYPE[t];\n\t\tif (indexType) types.add(indexType);\n\t}\n\treturn [...types];\n}\n\n/** Index event_types a subgraph's sources require. Empty when sources are absent. */\nexport function referencedIndexEventTypes(\n\tsubgraph: SubgraphDefinition,\n): string[] {\n\tif (!subgraph.sources) return [];\n\treturn indexEventTypesForFilterTypes(\n\t\tsourceFilters(subgraph).map((f) => f.type),\n\t);\n}\n\n/**\n * streams-index eligibility: every source must be a known event-type or\n * contract_call/contract_deploy filter (no array-style sources, which leak the\n * unreconstructable `_eventId`). Trait scope IS allowed — trait resolution\n * reads the contract registry on the platform DB (`targetDb`), which the\n * processor always holds, so it's source-independent. Everything else stays on\n * the DB tap.\n */\nexport function isStreamsIndexEligible(subgraph: SubgraphDefinition): boolean {\n\tif (Array.isArray(subgraph.sources)) return false;\n\tconst filters = sourceFilters(subgraph);\n\tif (filters.length === 0) return false;\n\tfor (const f of filters) {\n\t\tconst known =\n\t\t\tEVENT_FILTER_TO_INDEX_TYPE[f.type] || TX_SOURCE_TYPES.has(f.type);\n\t\tif (!known) return false;\n\t}\n\treturn true;\n}\n\n/** Streams clock + Index data plane, reconstructed into raw BlockData rows. */\nexport class PublicApiBlockSource implements BlockSource {\n\tconstructor(\n\t\tprivate readonly http: IndexHttpClient,\n\t\tprivate readonly eventTypes: string[],\n\t\t/** When set, enables the sparse-scan probe (event-scoped subgraphs). */\n\t\tprivate readonly probeTargets?: SparseProbeTarget[],\n\t\t/** False for event-only subgraphs → skip walkTransactions, synthesize the\n\t\t * tx from joined event context. Defaults true (safe / unchanged). */\n\t\tprivate readonly needsTransactions = true,\n\t) {}\n\n\t/** Lowest height in (after, until] any probe target hits, or null. */\n\tasync nextDataHeight(\n\t\tafterHeight: number,\n\t\tuntilHeight: number,\n\t): Promise<number | null> {\n\t\tif (!this.probeTargets?.length) return afterHeight + 1;\n\t\tconst hits = await Promise.all(\n\t\t\tthis.probeTargets.map((t) =>\n\t\t\t\tthis.http.firstEventHeight(\n\t\t\t\t\tt.eventType,\n\t\t\t\t\tafterHeight + 1,\n\t\t\t\t\tuntilHeight,\n\t\t\t\t\tt.contractId,\n\t\t\t\t),\n\t\t\t),\n\t\t);\n\t\tconst found = hits.filter((h): h is number => h !== null);\n\t\treturn found.length ? Math.min(...found) : null;\n\t}\n\n\tgetTip(opts?: { wait?: number; knownHeight?: number }): Promise<number> {\n\t\t// VM rows land with ingest. Decoded Index tip can lag; use the source\n\t\t// field when this loader fetches any vm type.\n\t\tif (this.eventTypes.some((t) => VM_INDEX_EVENT_TYPES.has(t))) {\n\t\t\treturn this.http.getIndexSourceTip(opts);\n\t\t}\n\t\t// Scope the tip (and, with `wait`, what counts as \"nothing new\") to\n\t\t// exactly the classic types THIS source reads instead of the global\n\t\t// cross-decoder floor. Without this, any of the ~15 classic decoders\n\t\t// committing — most unrelated to what this source actually walks —\n\t\t// moves the global floor and makes an unrelated `wait` return early.\n\t\treturn this.http.getIndexTip({ ...opts, eventTypes: this.eventTypes });\n\t}\n\n\t/** Reads the SAME envelope `getTip()` just cached on `this.http` — call\n\t * this only after `getTip()` has resolved at least once. */\n\tgetDecodedHeights(): Record<string, number | null> | undefined {\n\t\treturn this.http.getDecodedHeights();\n\t}\n\n\tasync loadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\t// Event-only subgraphs join tx context onto events (withTx) and skip the\n\t\t// walkTransactions over-fetch entirely; tx-level sources fetch real txs.\n\t\tconst withTx = !this.needsTransactions;\n\t\tconst [blocks, txRows, eventLists] = await Promise.all([\n\t\t\tthis.http.walkBlocks(fromHeight, toHeight),\n\t\t\tthis.needsTransactions\n\t\t\t\t? this.http.walkTransactions(fromHeight, toHeight)\n\t\t\t\t: Promise.resolve<IndexTransactionRow[]>([]),\n\t\t\tPromise.all(\n\t\t\t\tthis.eventTypes.map((t) =>\n\t\t\t\t\tthis.http.walkEvents(t, fromHeight, toHeight, withTx),\n\t\t\t\t),\n\t\t\t),\n\t\t]);\n\n\t\tconst map = new Map<number, BlockData>();\n\t\t// Seed every canonical height (incl. empty blocks) so catch-up doesn't\n\t\t// file them as gaps.\n\t\tfor (const b of blocks) {\n\t\t\tmap.set(b.block_height, {\n\t\t\t\tblock: reconstructBlock(b),\n\t\t\t\ttxs: [],\n\t\t\t\tevents: [],\n\t\t\t});\n\t\t}\n\t\t// For event-only subgraphs, materialize only the event-bearing txs from\n\t\t// joined event context instead of every transaction in the range.\n\t\tconst txs = this.needsTransactions\n\t\t\t? txRows.map(reconstructTransaction)\n\t\t\t: synthesizeTxsFromEvents(eventLists.flat());\n\t\tfor (const t of txs) {\n\t\t\tmap.get(t.block_height)?.txs.push(t);\n\t\t}\n\t\tfor (const list of eventLists) {\n\t\t\tfor (const e of list) {\n\t\t\t\tconst bd = map.get(e.block_height);\n\t\t\t\tif (!bd) continue;\n\t\t\t\t// vm rows ride their own clock: never merged into `events`, whose\n\t\t\t\t// order is classic event_index.\n\t\t\t\tif (VM_INDEX_EVENT_TYPES.has(e.event_type)) {\n\t\t\t\t\tbd.vmEvents ??= [];\n\t\t\t\t\tbd.vmEvents.push(reconstructEvent(e));\n\t\t\t\t} else {\n\t\t\t\t\tbd.events.push(reconstructEvent(e));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Canonical ordering — multi-type event walks merge here, per clock.\n\t\tfor (const bd of map.values()) {\n\t\t\tbd.txs.sort((a, b) => a.tx_index - b.tx_index);\n\t\t\tbd.events.sort((a, b) => a.event_index - b.event_index);\n\t\t\tbd.vmEvents?.sort((a, b) => a.event_index - b.event_index);\n\t\t}\n\t\treturn map;\n\t}\n}\n\n/**\n * Wraps a primary source and falls back to a secondary, per call, when the\n * primary throws. Use it to make the HTTP (Streams+Index) plane a SOFT dependency:\n * if api is unavailable, the processor reads the Postgres tap and keeps advancing\n * instead of stalling. Safe to mix mid-stream — both taps read the same canonical\n * chain at the same heights and the cursor is forward-only. Stateless (no breaker)\n * so it's failover-safe across replicas; the primary is retried every call and\n * resumes transparently once healthy.\n */\nexport class FallbackBlockSource implements BlockSource {\n\t/** True when the LAST `getTip()` call actually used the primary — guards\n\t * `getDecodedHeights()` against returning the primary's stale cached\n\t * envelope from an earlier success after a call that just fell back. */\n\tprivate lastTipFromPrimary = false;\n\n\tconstructor(\n\t\tprivate readonly primary: BlockSource,\n\t\tprivate readonly fallback: BlockSource,\n\t) {}\n\n\tasync getTip(opts?: {\n\t\twait?: number;\n\t\tknownHeight?: number;\n\t}): Promise<number> {\n\t\ttry {\n\t\t\tconst tip = await this.primary.getTip(opts);\n\t\t\tthis.lastTipFromPrimary = true;\n\t\t\treturn tip;\n\t\t} catch (err) {\n\t\t\tlogger.warn(\"block source primary getTip failed — using DB tap\", {\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\tthis.lastTipFromPrimary = false;\n\t\t\t// The DB tap has no wait/long-poll notion — a plain read is already\n\t\t\t// instant, so `opts` is dropped here rather than forwarded.\n\t\t\treturn this.fallback.getTip();\n\t\t}\n\t}\n\n\t/** Only meaningful right after a `getTip()` that used the primary — the\n\t * fallback (Postgres tap) has no such notion, and returning the\n\t * primary's stale cache after a fallback would mismatch the returned\n\t * tip. Local mode's own `decoderBoundTip` never calls this. */\n\tgetDecodedHeights(): Record<string, number | null> | undefined {\n\t\tif (!this.lastTipFromPrimary) return undefined;\n\t\treturn this.primary.getDecodedHeights?.();\n\t}\n\n\tasync loadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\ttry {\n\t\t\treturn await this.primary.loadBlockRange(fromHeight, toHeight);\n\t\t} catch (err) {\n\t\t\tlogger.warn(\"block source primary loadBlockRange failed — using DB tap\", {\n\t\t\t\tfrom: fromHeight,\n\t\t\t\tto: toHeight,\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\treturn this.fallback.loadBlockRange(fromHeight, toHeight);\n\t\t}\n\t}\n\n\t/** Probe via the primary only; a probe failure just means \"no skip\" —\n\t * the caller falls back to plain batch advancement. */\n\tasync nextDataHeight(\n\t\tafterHeight: number,\n\t\tuntilHeight: number,\n\t): Promise<number | null> {\n\t\tif (!this.primary.nextDataHeight) return afterHeight + 1;\n\t\ttry {\n\t\t\treturn await this.primary.nextDataHeight(afterHeight, untilHeight);\n\t\t} catch {\n\t\t\treturn afterHeight + 1;\n\t\t}\n\t}\n}\n\nconst postgresBlockSource = new PostgresBlockSource();\n\n/**\n * HTTP (Streams+Index) chain source for a set of decoded event types, wrapped so\n * it falls back to the Postgres tap when api is down. Used by the chain-trigger\n * evaluator (which isn't subgraph-scoped, so it can't go through resolveBlockSource).\n *\n * `needsTransactions` defaults true (safe/unchanged) but the evaluator passes\n * `chainSubsNeedTransactions(chainSubs)` so a tick with no contract_call/deploy\n * trigger skips `walkTransactions` entirely — one fewer HTTP round trip and a\n * smaller `walkEvents` payload (no `tx_context` join) on every such tick.\n *\n * `httpClient` defaults to a fresh client (unchanged behavior) — pass one in\n * to reuse across calls. The evaluator's own long-lived loop does this so\n * `IndexHttpClient.waitIsSupported()` (plan-063 3.5) reflects what THIS\n * server actually supports instead of resetting every tick.\n */\nexport function buildChainBlockSource(\n\teventTypes: string[],\n\tneedsTransactions = true,\n\thttpClient: IndexHttpClient = buildHttpClient(),\n): BlockSource {\n\treturn new FallbackBlockSource(\n\t\tnew PublicApiBlockSource(\n\t\t\thttpClient,\n\t\t\teventTypes,\n\t\t\tundefined,\n\t\t\tneedsTransactions,\n\t\t),\n\t\tpostgresBlockSource,\n\t);\n}\n\nexport function buildHttpClient(): IndexHttpClient {\n\treturn createInternalIndexHttpClient();\n}\n\n/**\n * Resolve the block source for a subgraph. `SUBGRAPH_SOURCE=streams-index`\n * opts eligible subgraphs onto the public Streams clock + Index data;\n * `SUBGRAPH_SOURCE=observer-http` pages internal observer-events (experimental).\n * Default stays on the Postgres tap.\n */\nexport function resolveBlockSource(subgraph?: SubgraphDefinition): BlockSource {\n\tif (process.env.SUBGRAPH_SOURCE === \"observer-http\") {\n\t\tconst baseUrl = process.env.OBSERVER_HTTP_URL;\n\t\tif (!baseUrl) {\n\t\t\tthrow new Error(\n\t\t\t\t\"SUBGRAPH_SOURCE=observer-http requires OBSERVER_HTTP_URL\",\n\t\t\t);\n\t\t}\n\t\treturn new ObserverHttpBlockSource({\n\t\t\tbaseUrl,\n\t\t\ttoken: process.env.OBSERVER_HTTP_EXPORT_TOKEN || null,\n\t\t});\n\t}\n\tif (\n\t\tprocess.env.SUBGRAPH_SOURCE === \"streams-index\" &&\n\t\tsubgraph &&\n\t\tisStreamsIndexEligible(subgraph)\n\t) {\n\t\t// Soft-depend on api: fall back to the Postgres tap per-call if the HTTP\n\t\t// plane is down, so the processor keeps advancing instead of stalling.\n\t\treturn new FallbackBlockSource(\n\t\t\tnew PublicApiBlockSource(\n\t\t\t\tbuildHttpClient(),\n\t\t\t\treferencedIndexEventTypes(subgraph),\n\t\t\t\tcanSparseScan(subgraph) ? sparseProbeTargets(subgraph) : undefined,\n\t\t\t\tneedsTransactionData(subgraph),\n\t\t\t),\n\t\t\tpostgresBlockSource,\n\t\t);\n\t}\n\tif (process.env.SUBGRAPH_SOURCE === \"streams-index\" && subgraph) {\n\t\tlogger.debug(\"Subgraph not streams-index eligible, using DB tap\", {\n\t\t\tsubgraph: subgraph.name,\n\t\t});\n\t}\n\treturn postgresBlockSource;\n}\n",
16
+ "import { getSourceDb } from \"@secondlayer/shared/db\";\nimport type { Transaction } from \"@secondlayer/shared/db\";\nimport {\n\ttype IndexEventRow,\n\ttype IndexHttpClient,\n\ttype IndexTransactionRow,\n\tcreateInternalIndexHttpClient,\n} from \"@secondlayer/shared/index-http\";\nimport { logger } from \"@secondlayer/shared/logger\";\nimport type { SubgraphDefinition, SubgraphFilter } from \"../types.ts\";\nimport { type BlockData, loadBlockRange } from \"./batch-loader.ts\";\nimport { ObserverHttpBlockSource } from \"./observer-http-source.ts\";\nimport {\n\treconstructBlock,\n\treconstructEvent,\n\treconstructTransaction,\n\treconstructTxFromEventRow,\n} from \"./reconstruct.ts\";\n\n/**\n * Where the subgraph runtime reads canonical chain data. Today it taps the\n * indexer Postgres directly (`PostgresBlockSource`); the re-point adds a\n * `PublicApiBlockSource` that consumes the Streams clock + Index data over\n * HTTP. `matchSources` / handlers / flush / outbox are unchanged — only the\n * loader + tip swap behind this seam.\n */\nexport interface BlockSource {\n\t/**\n\t * Highest canonical block height available to process.\n\t *\n\t * `opts` is an HTTP-plane long-poll hint, ignored by any\n\t * source with no such notion (the Postgres tap: a local DB read is already\n\t * instant, nothing to wait on). `knownHeight` is the last tip THIS caller\n\t * observed; passing `wait` without it is a no-op — the server needs a\n\t * baseline to decide whether anything has changed.\n\t */\n\tgetTip(opts?: { wait?: number; knownHeight?: number }): Promise<number>;\n\t/** Canonical block data for [fromHeight, toHeight], keyed by height. */\n\tloadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>>;\n\t/** Sparse-scan probe: lowest height in (afterHeight, untilHeight] holding\n\t * an event this source's subgraph could match, or null when the rest of\n\t * the range is empty. Optional — only event-scoped sources support it. */\n\tnextDataHeight?(\n\t\tafterHeight: number,\n\t\tuntilHeight: number,\n\t): Promise<number | null>;\n\t/**\n\t * Per-decoder committed heights from the SAME response `getTip()` just\n\t * fetched (no extra request) — undefined for a source with no such notion\n\t * (the Postgres tap: local mode reads `decoder_checkpoints` directly and\n\t * never needs this). Lets a caller (the chain evaluator) narrow its own\n\t * bound to the decoders it actually reads instead of `getTip()`'s\n\t * conservative cross-decoder floor. Call AFTER `getTip()`.\n\t */\n\tgetDecodedHeights?(): Record<string, number | null> | undefined;\n}\n\n/** A (decoded event type, optional contract scope) pair the sparse probe\n * checks. Contract scoping is what makes token-subgraph reindexes leap over\n * everything that isn't their token. */\nexport type SparseProbeTarget = { eventType: string; contractId?: string };\n\n/** Sparse scanning is sound only when EVERY source is an event-type filter —\n * a contract_call/contract_deploy source matches transactions, which the\n * event probe can't see. */\nexport function canSparseScan(subgraph: SubgraphDefinition): boolean {\n\tif (Array.isArray(subgraph.sources)) return false;\n\tconst filters = sourceFilters(subgraph);\n\tif (filters.length === 0) return false;\n\treturn filters.every((f) => Boolean(EVENT_FILTER_TO_INDEX_TYPE[f.type]));\n}\n\n/** Probe targets for a subgraph's filters: decoded type + contract scope when\n * the filter pins one (assetIdentifier \"SP….contract::asset\" or contractId). */\nexport function sparseProbeTargets(\n\tsubgraph: SubgraphDefinition,\n): SparseProbeTarget[] {\n\tconst targets = new Map<string, SparseProbeTarget>();\n\tfor (const f of sourceFilters(subgraph)) {\n\t\tconst eventType = EVENT_FILTER_TO_INDEX_TYPE[f.type];\n\t\tif (!eventType) continue;\n\t\tconst scoped = f as { assetIdentifier?: string; contractId?: string };\n\t\tconst contractId =\n\t\t\tscoped.contractId ?? scoped.assetIdentifier?.split(\"::\")[0];\n\t\tconst key = `${eventType}|${contractId ?? \"\"}`;\n\t\ttargets.set(key, { eventType, ...(contractId ? { contractId } : {}) });\n\t}\n\treturn [...targets.values()];\n}\n\n/** Reads directly from the shared indexer Postgres (the original behavior). */\nexport class PostgresBlockSource implements BlockSource {\n\tasync getTip(): Promise<number> {\n\t\tconst progress = await getSourceDb()\n\t\t\t.selectFrom(\"index_progress\")\n\t\t\t.selectAll()\n\t\t\t.where(\"network\", \"=\", process.env.NETWORK ?? \"mainnet\")\n\t\t\t.executeTakeFirst();\n\t\treturn progress ? Number(progress.highest_seen_block) : 0;\n\t}\n\n\tloadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\treturn loadBlockRange(getSourceDb(), fromHeight, toHeight);\n\t}\n}\n\n// Subgraph source filter types that map to a decoded Index event_type. The\n// `_event` suffix is the runtime's raw form; print is keyed `print_event`.\nconst EVENT_FILTER_TO_INDEX_TYPE: Record<string, string> = {\n\tstx_transfer: \"stx_transfer\",\n\tstx_mint: \"stx_mint\",\n\tstx_burn: \"stx_burn\",\n\tstx_lock: \"stx_lock\",\n\tft_transfer: \"ft_transfer\",\n\tft_mint: \"ft_mint\",\n\tft_burn: \"ft_burn\",\n\tnft_transfer: \"nft_transfer\",\n\tnft_mint: \"nft_mint\",\n\tnft_burn: \"nft_burn\",\n\tprint_event: \"print\",\n\tnested_contract_call: \"nested_contract_call\",\n\tvar_set: \"var_set\",\n\tmap_set: \"map_set\",\n\tmap_insert: \"map_insert\",\n\tmap_delete: \"map_delete\",\n};\n\n// Tx-level source types — matched against /v1/index/transactions, not events.\nconst TX_SOURCE_TYPES = new Set([\"contract_call\", \"contract_deploy\"]);\n// Second clock (ordinal). Never part of a tx's classic event set: a\n// contract_call/contract_deploy source fetches every CLASSIC type, and vm\n// types are fetched only when a vm source names them.\nexport const VM_INDEX_EVENT_TYPES: ReadonlySet<string> = new Set([\n\t\"nested_contract_call\",\n\t\"var_set\",\n\t\"map_set\",\n\t\"map_insert\",\n\t\"map_delete\",\n]);\nconst CLASSIC_INDEX_EVENT_TYPES = [\n\t...new Set(Object.values(EVENT_FILTER_TO_INDEX_TYPE)),\n].filter((t) => !VM_INDEX_EVENT_TYPES.has(t));\n\nfunction sourceFilters(subgraph: SubgraphDefinition): SubgraphFilter[] {\n\tconst sources = subgraph.sources;\n\treturn Array.isArray(sources)\n\t\t? (sources as SubgraphFilter[])\n\t\t: Object.values(sources as Record<string, SubgraphFilter>);\n}\n\n/**\n * True when any source matches transactions (contract_call / contract_deploy):\n * the handler receives the full tx, so the loader must fetch real transactions.\n * Event-only subgraphs skip walkTransactions and synthesize the tx from joined\n * event context instead (the ~37x reindex over-fetch; see indexing-speed plan).\n */\nexport function needsTransactionData(subgraph: SubgraphDefinition): boolean {\n\treturn sourceFilters(subgraph).some((f) => TX_SOURCE_TYPES.has(f.type));\n}\n\n/** Build the (few) event-bearing txs from joined event context, one per tx_id —\n * the event-only replacement for draining every transaction in the range. */\nfunction synthesizeTxsFromEvents(events: IndexEventRow[]): Transaction[] {\n\tconst byId = new Map<string, Transaction>();\n\tfor (const e of events) {\n\t\tif (!byId.has(e.tx_id)) byId.set(e.tx_id, reconstructTxFromEventRow(e));\n\t}\n\treturn [...byId.values()];\n}\n\n/**\n * The Index event_types the loader must fetch for a set of source filter types.\n * A contract_call/contract_deploy source matches a tx and hands its FULL event\n * set to the handler, so when one is present we fetch every classic event type\n * (the matched tx's events must be complete); otherwise just the referenced\n * types. VM types ride a second clock and are fetched only when referenced.\n * Shared by the subgraph loader and the chain-trigger evaluator.\n */\nexport function indexEventTypesForFilterTypes(filterTypes: string[]): string[] {\n\tconst types = new Set<string>();\n\tif (filterTypes.some((t) => TX_SOURCE_TYPES.has(t))) {\n\t\tfor (const t of CLASSIC_INDEX_EVENT_TYPES) types.add(t);\n\t}\n\tfor (const t of filterTypes) {\n\t\tconst indexType = EVENT_FILTER_TO_INDEX_TYPE[t];\n\t\tif (indexType) types.add(indexType);\n\t}\n\treturn [...types];\n}\n\n/** Index event_types a subgraph's sources require. Empty when sources are absent. */\nexport function referencedIndexEventTypes(\n\tsubgraph: SubgraphDefinition,\n): string[] {\n\tif (!subgraph.sources) return [];\n\treturn indexEventTypesForFilterTypes(\n\t\tsourceFilters(subgraph).map((f) => f.type),\n\t);\n}\n\n/**\n * streams-index eligibility: every source must be a known event-type or\n * contract_call/contract_deploy filter (no array-style sources, which leak the\n * unreconstructable `_eventId`). Trait scope IS allowed — trait resolution\n * reads the contract registry on the platform DB (`targetDb`), which the\n * processor always holds, so it's source-independent. Everything else stays on\n * the DB tap.\n */\nexport function isStreamsIndexEligible(subgraph: SubgraphDefinition): boolean {\n\tif (Array.isArray(subgraph.sources)) return false;\n\tconst filters = sourceFilters(subgraph);\n\tif (filters.length === 0) return false;\n\tfor (const f of filters) {\n\t\tconst known =\n\t\t\tEVENT_FILTER_TO_INDEX_TYPE[f.type] || TX_SOURCE_TYPES.has(f.type);\n\t\tif (!known) return false;\n\t}\n\treturn true;\n}\n\n/** Streams clock + Index data plane, reconstructed into raw BlockData rows. */\nexport class PublicApiBlockSource implements BlockSource {\n\tconstructor(\n\t\tprivate readonly http: IndexHttpClient,\n\t\tprivate readonly eventTypes: string[],\n\t\t/** When set, enables the sparse-scan probe (event-scoped subgraphs). */\n\t\tprivate readonly probeTargets?: SparseProbeTarget[],\n\t\t/** False for event-only subgraphs → skip walkTransactions, synthesize the\n\t\t * tx from joined event context. Defaults true (safe / unchanged). */\n\t\tprivate readonly needsTransactions = true,\n\t) {}\n\n\t/** Lowest height in (after, until] any probe target hits, or null. */\n\tasync nextDataHeight(\n\t\tafterHeight: number,\n\t\tuntilHeight: number,\n\t): Promise<number | null> {\n\t\tif (!this.probeTargets?.length) return afterHeight + 1;\n\t\tconst hits = await Promise.all(\n\t\t\tthis.probeTargets.map((t) =>\n\t\t\t\tthis.http.firstEventHeight(\n\t\t\t\t\tt.eventType,\n\t\t\t\t\tafterHeight + 1,\n\t\t\t\t\tuntilHeight,\n\t\t\t\t\tt.contractId,\n\t\t\t\t),\n\t\t\t),\n\t\t);\n\t\tconst found = hits.filter((h): h is number => h !== null);\n\t\treturn found.length ? Math.min(...found) : null;\n\t}\n\n\tgetTip(opts?: { wait?: number; knownHeight?: number }): Promise<number> {\n\t\t// VM rows land with ingest. Decoded Index tip can lag; use the source\n\t\t// field when this loader fetches any vm type.\n\t\tif (this.eventTypes.some((t) => VM_INDEX_EVENT_TYPES.has(t))) {\n\t\t\treturn this.http.getIndexSourceTip(opts);\n\t\t}\n\t\t// Scope the tip (and, with `wait`, what counts as \"nothing new\") to\n\t\t// exactly the classic types THIS source reads instead of the global\n\t\t// cross-decoder floor. Without this, any of the ~15 classic decoders\n\t\t// committing — most unrelated to what this source actually walks —\n\t\t// moves the global floor and makes an unrelated `wait` return early.\n\t\treturn this.http.getIndexTip({ ...opts, eventTypes: this.eventTypes });\n\t}\n\n\t/** Reads the SAME envelope `getTip()` just cached on `this.http` — call\n\t * this only after `getTip()` has resolved at least once. */\n\tgetDecodedHeights(): Record<string, number | null> | undefined {\n\t\treturn this.http.getDecodedHeights();\n\t}\n\n\tasync loadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\t// Event-only subgraphs join tx context onto events (withTx) and skip the\n\t\t// walkTransactions over-fetch entirely; tx-level sources fetch real txs.\n\t\tconst withTx = !this.needsTransactions;\n\t\tconst [blocks, txRows, eventLists] = await Promise.all([\n\t\t\tthis.http.walkBlocks(fromHeight, toHeight),\n\t\t\tthis.needsTransactions\n\t\t\t\t? this.http.walkTransactions(fromHeight, toHeight)\n\t\t\t\t: Promise.resolve<IndexTransactionRow[]>([]),\n\t\t\tPromise.all(\n\t\t\t\tthis.eventTypes.map((t) =>\n\t\t\t\t\tthis.http.walkEvents(t, fromHeight, toHeight, withTx),\n\t\t\t\t),\n\t\t\t),\n\t\t]);\n\n\t\tconst map = new Map<number, BlockData>();\n\t\t// Seed every canonical height (incl. empty blocks) so catch-up doesn't\n\t\t// file them as gaps.\n\t\tfor (const b of blocks) {\n\t\t\tmap.set(b.block_height, {\n\t\t\t\tblock: reconstructBlock(b),\n\t\t\t\ttxs: [],\n\t\t\t\tevents: [],\n\t\t\t});\n\t\t}\n\t\t// For event-only subgraphs, materialize only the event-bearing txs from\n\t\t// joined event context instead of every transaction in the range.\n\t\tconst txs = this.needsTransactions\n\t\t\t? txRows.map(reconstructTransaction)\n\t\t\t: synthesizeTxsFromEvents(eventLists.flat());\n\t\tfor (const t of txs) {\n\t\t\tmap.get(t.block_height)?.txs.push(t);\n\t\t}\n\t\tfor (const list of eventLists) {\n\t\t\tfor (const e of list) {\n\t\t\t\tconst bd = map.get(e.block_height);\n\t\t\t\tif (!bd) continue;\n\t\t\t\t// vm rows ride their own clock: never merged into `events`, whose\n\t\t\t\t// order is classic event_index.\n\t\t\t\tif (VM_INDEX_EVENT_TYPES.has(e.event_type)) {\n\t\t\t\t\tbd.vmEvents ??= [];\n\t\t\t\t\tbd.vmEvents.push(reconstructEvent(e));\n\t\t\t\t} else {\n\t\t\t\t\tbd.events.push(reconstructEvent(e));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Canonical ordering — multi-type event walks merge here, per clock.\n\t\tfor (const bd of map.values()) {\n\t\t\tbd.txs.sort((a, b) => a.tx_index - b.tx_index);\n\t\t\tbd.events.sort((a, b) => a.event_index - b.event_index);\n\t\t\tbd.vmEvents?.sort((a, b) => a.event_index - b.event_index);\n\t\t}\n\t\treturn map;\n\t}\n}\n\n/**\n * Wraps a primary source and falls back to a secondary, per call, when the\n * primary throws. Use it to make the HTTP (Streams+Index) plane a SOFT dependency:\n * if api is unavailable, the processor reads the Postgres tap and keeps advancing\n * instead of stalling. Safe to mix mid-stream — both taps read the same canonical\n * chain at the same heights and the cursor is forward-only. Stateless (no breaker)\n * so it's failover-safe across replicas; the primary is retried every call and\n * resumes transparently once healthy.\n */\nexport class FallbackBlockSource implements BlockSource {\n\t/** True when the LAST `getTip()` call actually used the primary — guards\n\t * `getDecodedHeights()` against returning the primary's stale cached\n\t * envelope from an earlier success after a call that just fell back. */\n\tprivate lastTipFromPrimary = false;\n\n\tconstructor(\n\t\tprivate readonly primary: BlockSource,\n\t\tprivate readonly fallback: BlockSource,\n\t) {}\n\n\tasync getTip(opts?: {\n\t\twait?: number;\n\t\tknownHeight?: number;\n\t}): Promise<number> {\n\t\ttry {\n\t\t\tconst tip = await this.primary.getTip(opts);\n\t\t\tthis.lastTipFromPrimary = true;\n\t\t\treturn tip;\n\t\t} catch (err) {\n\t\t\tlogger.warn(\"block source primary getTip failed — using DB tap\", {\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\tthis.lastTipFromPrimary = false;\n\t\t\t// The DB tap has no wait/long-poll notion — a plain read is already\n\t\t\t// instant, so `opts` is dropped here rather than forwarded.\n\t\t\treturn this.fallback.getTip();\n\t\t}\n\t}\n\n\t/** Only meaningful right after a `getTip()` that used the primary — the\n\t * fallback (Postgres tap) has no such notion, and returning the\n\t * primary's stale cache after a fallback would mismatch the returned\n\t * tip. Local mode's own `decoderBoundTip` never calls this. */\n\tgetDecodedHeights(): Record<string, number | null> | undefined {\n\t\tif (!this.lastTipFromPrimary) return undefined;\n\t\treturn this.primary.getDecodedHeights?.();\n\t}\n\n\tasync loadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\ttry {\n\t\t\treturn await this.primary.loadBlockRange(fromHeight, toHeight);\n\t\t} catch (err) {\n\t\t\tlogger.warn(\"block source primary loadBlockRange failed — using DB tap\", {\n\t\t\t\tfrom: fromHeight,\n\t\t\t\tto: toHeight,\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\treturn this.fallback.loadBlockRange(fromHeight, toHeight);\n\t\t}\n\t}\n\n\t/** Probe via the primary only; a probe failure just means \"no skip\" —\n\t * the caller falls back to plain batch advancement. */\n\tasync nextDataHeight(\n\t\tafterHeight: number,\n\t\tuntilHeight: number,\n\t): Promise<number | null> {\n\t\tif (!this.primary.nextDataHeight) return afterHeight + 1;\n\t\ttry {\n\t\t\treturn await this.primary.nextDataHeight(afterHeight, untilHeight);\n\t\t} catch {\n\t\t\treturn afterHeight + 1;\n\t\t}\n\t}\n}\n\nconst postgresBlockSource = new PostgresBlockSource();\n\n/**\n * HTTP (Streams+Index) chain source for a set of decoded event types, wrapped so\n * it falls back to the Postgres tap when api is down. Used by the chain-trigger\n * evaluator (which isn't subgraph-scoped, so it can't go through resolveBlockSource).\n *\n * `needsTransactions` defaults true (safe/unchanged) but the evaluator passes\n * `chainSubsNeedTransactions(chainSubs)` so a tick with no contract_call/deploy\n * trigger skips `walkTransactions` entirely — one fewer HTTP round trip and a\n * smaller `walkEvents` payload (no `tx_context` join) on every such tick.\n *\n * `httpClient` defaults to a fresh client (unchanged behavior) — pass one in\n * to reuse across calls. The evaluator's own long-lived loop does this so\n * `IndexHttpClient.waitIsSupported()` reflects what THIS\n * server actually supports instead of resetting every tick.\n */\nexport function buildChainBlockSource(\n\teventTypes: string[],\n\tneedsTransactions = true,\n\thttpClient: IndexHttpClient = buildHttpClient(),\n): BlockSource {\n\treturn new FallbackBlockSource(\n\t\tnew PublicApiBlockSource(\n\t\t\thttpClient,\n\t\t\teventTypes,\n\t\t\tundefined,\n\t\t\tneedsTransactions,\n\t\t),\n\t\tpostgresBlockSource,\n\t);\n}\n\nexport function buildHttpClient(): IndexHttpClient {\n\treturn createInternalIndexHttpClient();\n}\n\n/**\n * Resolve the block source for a subgraph. `SUBGRAPH_SOURCE=streams-index`\n * opts eligible subgraphs onto the public Streams clock + Index data;\n * `SUBGRAPH_SOURCE=observer-http` pages internal observer-events (experimental).\n * Default stays on the Postgres tap.\n */\nexport function resolveBlockSource(subgraph?: SubgraphDefinition): BlockSource {\n\tif (process.env.SUBGRAPH_SOURCE === \"observer-http\") {\n\t\tconst baseUrl = process.env.OBSERVER_HTTP_URL;\n\t\tif (!baseUrl) {\n\t\t\tthrow new Error(\n\t\t\t\t\"SUBGRAPH_SOURCE=observer-http requires OBSERVER_HTTP_URL\",\n\t\t\t);\n\t\t}\n\t\treturn new ObserverHttpBlockSource({\n\t\t\tbaseUrl,\n\t\t\ttoken: process.env.OBSERVER_HTTP_EXPORT_TOKEN || null,\n\t\t});\n\t}\n\tif (\n\t\tprocess.env.SUBGRAPH_SOURCE === \"streams-index\" &&\n\t\tsubgraph &&\n\t\tisStreamsIndexEligible(subgraph)\n\t) {\n\t\t// Soft-depend on api: fall back to the Postgres tap per-call if the HTTP\n\t\t// plane is down, so the processor keeps advancing instead of stalling.\n\t\treturn new FallbackBlockSource(\n\t\t\tnew PublicApiBlockSource(\n\t\t\t\tbuildHttpClient(),\n\t\t\t\treferencedIndexEventTypes(subgraph),\n\t\t\t\tcanSparseScan(subgraph) ? sparseProbeTargets(subgraph) : undefined,\n\t\t\t\tneedsTransactionData(subgraph),\n\t\t\t),\n\t\t\tpostgresBlockSource,\n\t\t);\n\t}\n\tif (process.env.SUBGRAPH_SOURCE === \"streams-index\" && subgraph) {\n\t\tlogger.debug(\"Subgraph not streams-index eligible, using DB tap\", {\n\t\t\tsubgraph: subgraph.name,\n\t\t});\n\t}\n\treturn postgresBlockSource;\n}\n",
17
17
  "import type {\n\tBlock,\n\tDatabase,\n\tEvent,\n\tTransaction,\n} from \"@secondlayer/shared/db\";\nimport type { Kysely } from \"kysely\";\n\n/** A runtime event row. `clock: \"vm\"` marks an opt-in `vm_events` row whose\n * `event_index` is `ordinal` — a second ordinal that never sorts or\n * dedupes against classic `events.event_index`. */\nexport type RuntimeEvent = Event & { clock?: \"vm\" };\n\nexport interface BlockData {\n\tblock: Block;\n\ttxs: Transaction[];\n\t/** Classic `events` rows (Streams 1.0 clock). */\n\tevents: Event[];\n\t/** Opt-in `vm_events` rows on their own clock. Absent/empty on `\"*\"`\n\t * nodes and on archives collected without `storage`/`contract_calls`. */\n\tvmEvents?: RuntimeEvent[];\n}\n\n/** Stable synthetic id for a vm row. Distinct from the classic `tx#index`\n * so a print at event_index N and a map_set at ordinal N never share\n * an identity. */\nexport function vmEventId(txId: string, vmEventIndex: number): string {\n\treturn `${txId}#vm:${vmEventIndex}`;\n}\n\n/**\n * Load a range of blocks with their transactions and events in 4 parallel queries.\n * Returns a Map keyed by block height. Non-canonical blocks are excluded.\n */\nexport async function loadBlockRange(\n\tdb: Kysely<Database>,\n\tfromHeight: number,\n\ttoHeight: number,\n): Promise<Map<number, BlockData>> {\n\tconst [blocks, txs, events, vmRows] = await Promise.all([\n\t\tdb\n\t\t\t.selectFrom(\"blocks\")\n\t\t\t.selectAll()\n\t\t\t.where(\"height\", \">=\", fromHeight)\n\t\t\t.where(\"height\", \"<=\", toHeight)\n\t\t\t.where(\"canonical\", \"=\", true)\n\t\t\t.execute(),\n\t\tdb\n\t\t\t.selectFrom(\"transactions\")\n\t\t\t.selectAll()\n\t\t\t.where(\"block_height\", \">=\", fromHeight)\n\t\t\t.where(\"block_height\", \"<=\", toHeight)\n\t\t\t.execute(),\n\t\tdb\n\t\t\t.selectFrom(\"events\")\n\t\t\t.selectAll()\n\t\t\t.where(\"block_height\", \">=\", fromHeight)\n\t\t\t.where(\"block_height\", \"<=\", toHeight)\n\t\t\t.execute(),\n\t\tdb\n\t\t\t.selectFrom(\"vm_events\")\n\t\t\t.selectAll()\n\t\t\t.where(\"block_height\", \">=\", fromHeight)\n\t\t\t.where(\"block_height\", \"<=\", toHeight)\n\t\t\t.orderBy(\"ordinal\", \"asc\")\n\t\t\t.execute(),\n\t]);\n\n\t// Index by block height (coerce to number — bigint columns may return as string or number)\n\tconst txsByHeight = new Map<number, Transaction[]>();\n\tfor (const tx of txs) {\n\t\tconst h = Number(tx.block_height);\n\t\tconst list = txsByHeight.get(h) ?? [];\n\t\tlist.push(tx);\n\t\ttxsByHeight.set(h, list);\n\t}\n\n\tconst eventsByHeight = new Map<number, Event[]>();\n\tfor (const evt of events) {\n\t\tconst h = Number(evt.block_height);\n\t\tconst list = eventsByHeight.get(h) ?? [];\n\t\tlist.push(evt);\n\t\teventsByHeight.set(h, list);\n\t}\n\n\tconst vmByHeight = new Map<number, RuntimeEvent[]>();\n\tfor (const row of vmRows) {\n\t\tconst h = Number(row.block_height);\n\t\tconst list = vmByHeight.get(h) ?? [];\n\t\tlist.push({\n\t\t\tid: vmEventId(row.tx_id, Number(row.ordinal)),\n\t\t\ttx_id: row.tx_id,\n\t\t\tblock_height: h,\n\t\t\tevent_index: Number(row.ordinal),\n\t\t\ttype: row.type,\n\t\t\tdata: row.data,\n\t\t\tcreated_at: row.created_at,\n\t\t\tclock: \"vm\",\n\t\t} as RuntimeEvent);\n\t\tvmByHeight.set(h, list);\n\t}\n\n\tconst result = new Map<number, BlockData>();\n\tfor (const block of blocks) {\n\t\tconst h = Number(block.height);\n\t\tresult.set(h, {\n\t\t\tblock,\n\t\t\ttxs: txsByHeight.get(h) ?? [],\n\t\t\tevents: eventsByHeight.get(h) ?? [],\n\t\t\tvmEvents: vmByHeight.get(h) ?? [],\n\t\t});\n\t}\n\n\treturn result;\n}\n\n/**\n * Compute average events per block from a loaded batch.\n * Used for adaptive batch sizing.\n */\nexport function avgEventsPerBlock(batch: Map<number, BlockData>): number {\n\tif (batch.size === 0) return 0;\n\tlet totalEvents = 0;\n\tfor (const data of batch.values()) {\n\t\ttotalEvents += data.events.length;\n\t}\n\treturn totalEvents / batch.size;\n}\n",
18
18
  "import {\n\tVM_NODE_TO_STORED_TYPE,\n\ttype VmNodeEventType,\n} from \"@secondlayer/shared\";\nimport type { Block, Event, Transaction } from \"@secondlayer/shared/db\";\nimport {\n\ttype BlockData,\n\ttype RuntimeEvent,\n\tvmEventId,\n} from \"./batch-loader.ts\";\nimport type { BlockSource } from \"./block-source.ts\";\n\n/** Duplicated from indexer — do not import `@secondlayer/indexer` (cycle). */\nconst OBSERVER_HTTP_EXPORT_PATH = \"/internal/observer-events\";\n\nexport type ObserverHttpFetch = (\n\tinput: string | URL | Request,\n\tinit?: RequestInit,\n) => Promise<Response>;\n\nexport type ObserverHttpBlockSourceOpts = {\n\t/** e.g. http://127.0.0.1:3700 — trailing slash stripped */\n\tbaseUrl: string;\n\ttoken?: string | null;\n\tfetch?: ObserverHttpFetch;\n\t/** Unused on the wire; list filtering is server-side. */\n\tnetwork?: string;\n};\n\ntype ObserverEventsNext = {\n\tafter_height: number;\n\tafter_index_block_hash: string;\n};\n\ntype SbaObserverMessage = {\n\tpath: string;\n\tpayload: unknown;\n\tcontent_sha256: string;\n\tblock_height: number | null;\n\tindex_block_hash: string | null;\n\treceived_at?: string;\n};\n\ntype ObserverEventsResponse = {\n\tevents: SbaObserverMessage[];\n\tnext: ObserverEventsNext | null;\n};\n\ntype ObserverTipResponse = {\n\tblock_height: number;\n\tindex_block_hash: string | null;\n};\n\ntype ObserverTx = {\n\ttxid?: string;\n\ttx_index?: number;\n\ttx_type?: string;\n\tsender_address?: string;\n\tstatus?: string;\n\traw_result?: string | null;\n\traw_tx?: string;\n\tcontract_call?: { contract_id?: string; function_name?: string };\n\tsmart_contract?: { contract_id?: string };\n};\n\ntype ObserverEvent = {\n\ttxid?: string;\n\tevent_index?: number;\n\ttype?: string;\n\t[key: string]: unknown;\n};\n\ntype ObserverNewBlockPayload = {\n\tblock_height?: number;\n\tblock_hash?: string;\n\tparent_block_hash?: string;\n\tburn_block_height?: number;\n\tburn_block_hash?: string | null;\n\tindex_block_hash?: string | null;\n\ttimestamp?: number;\n\tblock_time?: number;\n\tburn_block_time?: number;\n\tburn_block_timestamp?: number;\n\ttransactions?: ObserverTx[];\n\tevents?: ObserverEvent[];\n\t/** Opt-in traces; omitted on `\"*\"` bodies. */\n\tvm_events?: ObserverVmTrace[];\n};\n\ntype ObserverVmTrace = {\n\ttxid?: string;\n\tcommitted?: boolean;\n\ttype?: string;\n\t[key: string]: unknown;\n};\n\nfunction stripTrailingSlash(url: string): string {\n\treturn url.replace(/\\/+$/, \"\");\n}\n\nfunction blockTimestamp(payload: ObserverNewBlockPayload): number {\n\tfor (const value of [\n\t\tpayload.timestamp,\n\t\tpayload.block_time,\n\t\tpayload.burn_block_time,\n\t\tpayload.burn_block_timestamp,\n\t]) {\n\t\tif (typeof value === \"number\" && Number.isFinite(value) && value > 0) {\n\t\t\treturn value;\n\t\t}\n\t}\n\treturn 0;\n}\n\nfunction eventData(event: ObserverEvent): unknown {\n\tif (typeof event.type === \"string\" && event.type in event) {\n\t\treturn event[event.type] ?? {};\n\t}\n\treturn {};\n}\n\n/** Map a `/new_block` observer payload into subgraph `BlockData`. No Hiro. */\nexport function mapNewBlockPayloadToBlockData(payload: unknown): BlockData {\n\tconst p = payload as ObserverNewBlockPayload;\n\tif (\n\t\ttypeof p.block_height !== \"number\" ||\n\t\t!Number.isFinite(p.block_height) ||\n\t\ttypeof p.index_block_hash !== \"string\" ||\n\t\tp.index_block_hash.length === 0\n\t) {\n\t\tthrow new Error(\n\t\t\t\"observer /new_block payload missing block_height or index_block_hash\",\n\t\t);\n\t}\n\n\tconst height = p.block_height;\n\tconst block = {\n\t\theight,\n\t\thash: p.block_hash ?? \"\",\n\t\tparent_hash: p.parent_block_hash ?? \"\",\n\t\tburn_block_height: p.burn_block_height ?? 0,\n\t\tburn_block_hash: p.burn_block_hash ?? null,\n\t\tindex_block_hash: p.index_block_hash,\n\t\ttimestamp: blockTimestamp(p),\n\t\tcanonical: true,\n\t\tcreated_at: new Date(0),\n\t} as Block;\n\n\tconst txs: Transaction[] = (p.transactions ?? []).map((tx) => {\n\t\tlet contractId: string | null = null;\n\t\tlet functionName: string | null = null;\n\t\tif (tx.tx_type === \"contract_call\" && tx.contract_call) {\n\t\t\tcontractId = tx.contract_call.contract_id ?? null;\n\t\t\tfunctionName = tx.contract_call.function_name ?? null;\n\t\t} else if (tx.tx_type === \"smart_contract\" && tx.smart_contract) {\n\t\t\tcontractId = tx.smart_contract.contract_id ?? null;\n\t\t}\n\t\treturn {\n\t\t\ttx_id: tx.txid ?? \"\",\n\t\t\tblock_height: height,\n\t\t\ttx_index: tx.tx_index ?? 0,\n\t\t\ttype: tx.tx_type ?? \"\",\n\t\t\tsender: tx.sender_address ?? \"\",\n\t\t\tstatus: tx.status ?? \"success\",\n\t\t\tcontract_id: contractId,\n\t\t\tfunction_name: functionName,\n\t\t\tfunction_args: [],\n\t\t\traw_result: tx.raw_result ?? null,\n\t\t\traw_tx: tx.raw_tx ?? \"\",\n\t\t\tcreated_at: new Date(0),\n\t\t} as Transaction;\n\t});\n\n\tconst events: Event[] = (p.events ?? []).map((event) => {\n\t\tconst txId = event.txid ?? \"\";\n\t\tconst eventIndex = event.event_index ?? 0;\n\t\treturn {\n\t\t\tid: `${txId}#${eventIndex}`,\n\t\t\ttx_id: txId,\n\t\t\tblock_height: height,\n\t\t\tevent_index: eventIndex,\n\t\t\ttype: event.type ?? \"\",\n\t\t\tdata: eventData(event),\n\t\t\tcreated_at: new Date(0),\n\t\t} as Event;\n\t});\n\n\t// Opt-in `vm_events` (present only when the observer subscribed to\n\t// `storage` / `contract_calls`). Node type → stored name; own clock.\n\tconst vmEvents: RuntimeEvent[] = [];\n\tfor (const [i, trace] of (p.vm_events ?? []).entries()) {\n\t\tconst nodeType = trace.type;\n\t\tif (typeof nodeType !== \"string\" || !(nodeType in VM_NODE_TO_STORED_TYPE))\n\t\t\tcontinue;\n\t\tconst stored = VM_NODE_TO_STORED_TYPE[nodeType as VmNodeEventType];\n\t\tconst body = trace[nodeType];\n\t\tif (!body || typeof body !== \"object\") continue;\n\t\tconst txId = trace.txid ?? \"\";\n\t\tconst vmIndex = i;\n\t\tvmEvents.push({\n\t\t\tid: vmEventId(txId, vmIndex),\n\t\t\ttx_id: txId,\n\t\t\tblock_height: height,\n\t\t\tevent_index: vmIndex,\n\t\t\ttype: stored,\n\t\t\tdata: body,\n\t\t\tcreated_at: new Date(0),\n\t\t\tclock: \"vm\",\n\t\t} as RuntimeEvent);\n\t}\n\n\treturn { block, txs, events, vmEvents };\n}\n\n/**\n * Experimental BlockSource: pages indexer `/internal/observer-events` and maps\n * raw `/new_block` bodies to `BlockData`. Not the production default.\n */\nexport class ObserverHttpBlockSource implements BlockSource {\n\tprivate readonly baseUrl: string;\n\tprivate readonly token: string | null;\n\tprivate readonly fetchFn: ObserverHttpFetch;\n\n\tconstructor(opts: ObserverHttpBlockSourceOpts) {\n\t\tthis.baseUrl = stripTrailingSlash(opts.baseUrl);\n\t\tthis.token = opts.token ?? null;\n\t\tthis.fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis);\n\t}\n\n\tprivate headers(): Record<string, string> {\n\t\tif (!this.token) return {};\n\t\treturn { Authorization: `Bearer ${this.token}` };\n\t}\n\n\tasync getTip(): Promise<number> {\n\t\tconst res = await this.fetchFn(\n\t\t\t`${this.baseUrl}${OBSERVER_HTTP_EXPORT_PATH}/tip`,\n\t\t\t{ headers: this.headers() },\n\t\t);\n\t\tif (!res.ok) {\n\t\t\tthrow new Error(\n\t\t\t\t`observer tip HTTP ${res.status}: ${await res.text().catch(() => \"\")}`,\n\t\t\t);\n\t\t}\n\t\tlet body: ObserverTipResponse;\n\t\ttry {\n\t\t\tbody = (await res.json()) as ObserverTipResponse;\n\t\t} catch (err) {\n\t\t\tthrow new Error(\n\t\t\t\t`observer tip invalid JSON: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t);\n\t\t}\n\t\tif (\n\t\t\ttypeof body.block_height !== \"number\" ||\n\t\t\t!Number.isFinite(body.block_height)\n\t\t) {\n\t\t\tthrow new Error(\"observer tip missing block_height\");\n\t\t}\n\t\treturn body.block_height;\n\t}\n\n\tasync loadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\tconst result = new Map<number, BlockData>();\n\t\tlet afterHeight: number | undefined =\n\t\t\tfromHeight > 0 ? fromHeight - 1 : undefined;\n\t\tlet afterIndexBlockHash: string | undefined;\n\n\t\tfor (;;) {\n\t\t\tconst url = new URL(`${this.baseUrl}${OBSERVER_HTTP_EXPORT_PATH}`);\n\t\t\turl.searchParams.set(\"path\", \"/new_block\");\n\t\t\turl.searchParams.set(\"limit\", \"100\");\n\t\t\tif (afterHeight != null) {\n\t\t\t\turl.searchParams.set(\"after_height\", String(afterHeight));\n\t\t\t}\n\t\t\tif (afterIndexBlockHash) {\n\t\t\t\turl.searchParams.set(\"after_index_block_hash\", afterIndexBlockHash);\n\t\t\t}\n\n\t\t\tconst res = await this.fetchFn(url.toString(), {\n\t\t\t\theaders: this.headers(),\n\t\t\t});\n\t\t\tif (!res.ok) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`observer events HTTP ${res.status}: ${await res.text().catch(() => \"\")}`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tlet body: ObserverEventsResponse;\n\t\t\ttry {\n\t\t\t\tbody = (await res.json()) as ObserverEventsResponse;\n\t\t\t} catch (err) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`observer events invalid JSON: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tlet pastTo = false;\n\t\t\tfor (const message of body.events ?? []) {\n\t\t\t\tif (message.path === \"/new_burn_block\") continue;\n\t\t\t\tconst height = (message.payload as ObserverNewBlockPayload)\n\t\t\t\t\t?.block_height;\n\t\t\t\tif (typeof height === \"number\" && height > toHeight) {\n\t\t\t\t\tpastTo = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tconst data = mapNewBlockPayloadToBlockData(message.payload);\n\t\t\t\tconst h = data.block.height;\n\t\t\t\tif (h >= fromHeight && h <= toHeight) {\n\t\t\t\t\tresult.set(h, data);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (pastTo || body.next == null) break;\n\t\t\tafterHeight = body.next.after_height;\n\t\t\tafterIndexBlockHash = body.next.after_index_block_hash;\n\t\t}\n\n\t\treturn result;\n\t}\n}\n",
19
19
  "import type { Block, Event, Transaction } from \"@secondlayer/shared/db\";\nimport type {\n\tIndexBlockRow,\n\tIndexEventRow,\n\tIndexTransactionRow,\n} from \"@secondlayer/shared/index-http\";\nimport { vmEventId } from \"./batch-loader.ts\";\n\nexport type {\n\tIndexBlockRow,\n\tIndexEventRow,\n\tIndexTransactionRow,\n} from \"@secondlayer/shared/index-http\";\n\n/**\n * Reconstruct the raw `blocks`/`events` row shapes the subgraph runtime expects\n * (the shapes `matchSources` + the handler runner read) from the decoded,\n * flattened rows the public Index API serves. This is what lets\n * `PublicApiBlockSource` feed the EXISTING pipeline byte-identically to the\n * Postgres tap.\n *\n * Two shape gaps the Index API can't avoid, handled here:\n * - Index event rows are flat (`event_type: \"ft_transfer\"`, columns\n * `sender`/`amount`/…); the runtime expects the node's nested `data` keyed by\n * a suffixed `type` (`ft_transfer_event`), with print under `contract_event`.\n * - nft/print Clarity values: the runtime decodes from the canonical hex\n * (`raw_value`), so we place the Index hex there. (The node's verbose\n * serde-tagged `value`, e.g. `{UInt:223}`, is not reproducible from hex and\n * is no longer read — see the nft tokenId normalization in runner.ts.)\n */\n\n/** ISO `block_time` (or null) → unix-seconds integer the runtime expects. */\nfunction isoToUnixSeconds(iso: string | null): number {\n\tif (!iso) return 0;\n\treturn Math.floor(new Date(iso).getTime() / 1000);\n}\n\nexport function reconstructBlock(b: IndexBlockRow): Block {\n\treturn {\n\t\theight: b.block_height,\n\t\thash: b.block_hash,\n\t\tparent_hash: b.parent_hash,\n\t\tburn_block_height: b.burn_block_height,\n\t\tburn_block_hash: b.burn_block_hash,\n\t\t// Present since the blocks endpoint started serving it; older API\n\t\t// deployments omit it, and a null propagates to a clean refusal in\n\t\t// `ctx.client.readOnly` rather than an unpinned tip read.\n\t\tindex_block_hash: b.index_block_hash ?? null,\n\t\ttimestamp: isoToUnixSeconds(b.block_time),\n\t\t// Index serves canonical rows only.\n\t\tcanonical: true,\n\t\tcreated_at: new Date(0),\n\t} as Block;\n}\n\nexport function reconstructTransaction(t: IndexTransactionRow): Transaction {\n\treturn {\n\t\ttx_id: t.tx_id,\n\t\tblock_height: t.block_height,\n\t\ttx_index: t.tx_index,\n\t\ttype: t.tx_type,\n\t\tsender: t.sender,\n\t\tstatus: t.status,\n\t\tcontract_id:\n\t\t\tt.contract_call?.contract_id ?? t.smart_contract?.contract_id ?? null,\n\t\tfunction_name: t.contract_call?.function_name ?? null,\n\t\t// Raw hex ClarityValues — the runner decodes them via its own deserializeCV\n\t\t// path, identical to the DB tap.\n\t\tfunction_args: t.contract_call?.function_args_hex ?? [],\n\t\traw_result: t.contract_call?.result_hex ?? null,\n\t\traw_tx: \"\",\n\t\tcreated_at: new Date(0),\n\t} as Transaction;\n}\n\n/**\n * Build a minimal canonical Transaction from an event row's joined tx context\n * (`tx_context=true` on /v1/index/events). For event-only subgraphs this lets\n * the loader materialize only the txs that actually carry a matched event,\n * instead of draining every transaction in the range (the ~37x reindex\n * over-fetch — see docs/sprints/indexing-speed/plan.md). `function_args` /\n * `raw_result` are empty: an event source never reads them (contract_call-only).\n */\nexport function reconstructTxFromEventRow(e: IndexEventRow): Transaction {\n\treturn {\n\t\ttx_id: e.tx_id,\n\t\tblock_height: e.block_height,\n\t\ttx_index: e.tx_index ?? 0,\n\t\ttype: e.tx_type ?? \"\",\n\t\tsender: e.tx_sender ?? \"\",\n\t\tstatus: e.tx_status ?? \"success\",\n\t\tcontract_id: e.tx_contract_id ?? null,\n\t\tfunction_name: e.tx_function_name ?? null,\n\t\tfunction_args: [],\n\t\traw_result: null,\n\t\traw_tx: \"\",\n\t\tcreated_at: new Date(0),\n\t} as Transaction;\n}\n\nexport function reconstructEvent(e: IndexEventRow): Event {\n\tconst base = {\n\t\t// Synthetic, deterministic id. The runtime only reads `id` for error logs\n\t\t// and the `*`-wildcard payload (which is streams-index-ineligible).\n\t\tid: `${e.tx_id}#${e.event_index}`,\n\t\ttx_id: e.tx_id,\n\t\tblock_height: e.block_height,\n\t\tevent_index: e.event_index,\n\t\tcreated_at: new Date(0),\n\t};\n\n\tswitch (e.event_type) {\n\t\tcase \"ft_transfer\":\n\t\tcase \"ft_mint\":\n\t\tcase \"ft_burn\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\ttype: `${e.event_type}_event`,\n\t\t\t\tdata: {\n\t\t\t\t\tasset_identifier: e.asset_identifier,\n\t\t\t\t\tsender: e.sender,\n\t\t\t\t\trecipient: e.recipient,\n\t\t\t\t\tamount: e.amount,\n\t\t\t\t},\n\t\t\t} as Event;\n\n\t\tcase \"nft_transfer\":\n\t\tcase \"nft_mint\":\n\t\tcase \"nft_burn\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\ttype: `${e.event_type}_event`,\n\t\t\t\tdata: {\n\t\t\t\t\tasset_identifier: e.asset_identifier,\n\t\t\t\t\tsender: e.sender,\n\t\t\t\t\trecipient: e.recipient,\n\t\t\t\t\t// Canonical hex → runner decodes tokenId from raw_value.\n\t\t\t\t\traw_value: e.value,\n\t\t\t\t},\n\t\t\t} as Event;\n\n\t\tcase \"stx_transfer\":\n\t\tcase \"stx_mint\":\n\t\tcase \"stx_burn\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\ttype: `${e.event_type}_event`,\n\t\t\t\tdata: {\n\t\t\t\t\tsender: e.sender,\n\t\t\t\t\trecipient: e.recipient,\n\t\t\t\t\tamount: e.amount,\n\t\t\t\t\t// Raw stx_transfer always carries memo (\"\" when empty); Index\n\t\t\t\t\t// returns null for empty — default to \"\" to match the DB tap.\n\t\t\t\t\t...(e.event_type === \"stx_transfer\" ? { memo: e.memo ?? \"\" } : {}),\n\t\t\t\t},\n\t\t\t} as Event;\n\n\t\tcase \"stx_lock\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\ttype: \"stx_lock_event\",\n\t\t\t\tdata: {\n\t\t\t\t\tlocked_address: e.sender,\n\t\t\t\t\tlocked_amount: e.amount,\n\t\t\t\t\tunlock_height: e.payload.unlock_height,\n\t\t\t\t},\n\t\t\t} as Event;\n\n\t\tcase \"print\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\ttype: \"contract_event\",\n\t\t\t\tdata: {\n\t\t\t\t\ttopic: e.payload.topic,\n\t\t\t\t\t// Index serves `contract_id`. Matcher + runner also accept\n\t\t\t\t\t// legacy `contract_identifier`; emit the current field.\n\t\t\t\t\tcontract_id: e.contract_id,\n\t\t\t\t\tvalue: e.payload.value,\n\t\t\t\t\traw_value: e.payload.raw_value,\n\t\t\t\t},\n\t\t\t} as Event;\n\n\t\tcase \"nested_contract_call\":\n\t\tcase \"var_set\":\n\t\tcase \"map_set\":\n\t\tcase \"map_insert\":\n\t\tcase \"map_delete\":\n\t\t\t// Second clock: `event_index` here is ordinal. Distinct id +\n\t\t\t// `clock` so it never dedupes or sorts against a classic row.\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tid: vmEventId(e.tx_id, e.event_index),\n\t\t\t\tclock: \"vm\",\n\t\t\t\ttype: e.event_type,\n\t\t\t\tdata: {\n\t\t\t\t\tcontract_identifier: e.contract_id,\n\t\t\t\t\t...(\"payload\" in e && e.payload && typeof e.payload === \"object\"\n\t\t\t\t\t\t? (e.payload as Record<string, unknown>)\n\t\t\t\t\t\t: {}),\n\t\t\t\t\tsender: \"sender\" in e ? e.sender : undefined,\n\t\t\t\t\tcaller: \"caller\" in e ? e.caller : undefined,\n\t\t\t\t\tfunction_name: \"function_name\" in e ? e.function_name : undefined,\n\t\t\t\t\tfunction_args: \"function_args\" in e ? e.function_args : undefined,\n\t\t\t\t\traw_result: \"raw_result\" in e ? e.raw_result : undefined,\n\t\t\t\t\tmap_name: \"map\" in e ? e.map : undefined,\n\t\t\t\t\tvar_name: \"var_name\" in e ? e.var_name : undefined,\n\t\t\t\t\traw_key: \"raw_key\" in e ? e.raw_key : undefined,\n\t\t\t\t\traw_value: \"raw_value\" in e ? e.raw_value : undefined,\n\t\t\t\t},\n\t\t\t} as Event;\n\t}\n}\n"
@@ -95,6 +95,14 @@ interface StartEmitterOptions {
95
95
  pollIntervalMs?: number;
96
96
  /** Retention sweep interval (ms). Defaults to 1 hour. */
97
97
  retentionIntervalMs?: number;
98
+ /** Bound for the returned stop function's clean-shutdown drain — how long
99
+ * it waits for the current claim pass + in-flight dispatches before
100
+ * giving up and reporting them abandoned. Defaults to
101
+ * `MAX_WEBHOOK_TIMEOUT_MS + 5_000` (a dispatch can't legitimately run
102
+ * longer than the webhook timeout ceiling). `MAX_WEBHOOK_TIMEOUT_MS` is
103
+ * fixed at module load from `WEBHOOK_TIMEOUT_MS_CEILING`, so this exists
104
+ * mainly for tests that need a short bound without a 5-minute wait. */
105
+ stopDrainDeadlineMs?: number;
98
106
  }
99
107
  declare function startEmitter(opts?: StartEmitterOptions): Promise<() => Promise<void>>;
100
108
  export { LOCK_WINDOW_MS, MAX_REDIRECT_HOPS, MAX_WEBHOOK_TIMEOUT_MS, StartEmitterOptions, __postToWebhookForTest, __setDnsLookupForTest, checkEgressAllowed, deliverTestEvent, startEmitter };
@@ -724,10 +724,22 @@ async function claimAndDrain(db, state, emitterId, trigger) {
724
724
  return 0;
725
725
  }
726
726
  state.claimInFlight = true;
727
+ const drainPromise = runClaimLoop(db, state, emitterId, trigger);
728
+ state.currentClaim = drainPromise;
729
+ try {
730
+ return await drainPromise;
731
+ } finally {
732
+ if (state.currentClaim === drainPromise)
733
+ state.currentClaim = null;
734
+ }
735
+ }
736
+ async function runClaimLoop(db, state, emitterId, trigger) {
727
737
  let totalClaimed = 0;
728
738
  try {
729
739
  let currentTrigger = trigger;
730
740
  for (;; ) {
741
+ if (!state.running)
742
+ break;
731
743
  state.claimPending = false;
732
744
  const result = await claimAndDispatchOnce(db, state, emitterId, currentTrigger);
733
745
  totalClaimed += result.claimed;
@@ -851,7 +863,11 @@ function pumpSub(db, state, subId) {
851
863
  if (!row)
852
864
  break;
853
865
  sq.inFlight++;
854
- runOne(db, state, sq, row);
866
+ const runPromise = runOne(db, state, sq, row);
867
+ state.inFlightRuns.add(runPromise);
868
+ runPromise.finally(() => {
869
+ state.inFlightRuns.delete(runPromise);
870
+ });
855
871
  }
856
872
  }
857
873
  async function runOne(db, state, sq, row) {
@@ -895,7 +911,9 @@ async function startEmitter(opts) {
895
911
  running: true,
896
912
  subQueues: new Map,
897
913
  claimInFlight: false,
898
- claimPending: false
914
+ claimPending: false,
915
+ currentClaim: null,
916
+ inFlightRuns: new Set
899
917
  };
900
918
  const pollIntervalMs = opts?.pollIntervalMs ?? 120000;
901
919
  const retentionIntervalMs = opts?.retentionIntervalMs ?? 60 * 60000;
@@ -964,7 +982,29 @@ async function startEmitter(opts) {
964
982
  clearInterval(retention);
965
983
  await stopNew();
966
984
  await stopChanged();
967
- logger3.info("[emitter] stopped", { id: emitterId });
985
+ const pendingRuns = new Set(state.inFlightRuns);
986
+ const drainTarget = pendingRuns.size;
987
+ const claimSettled = (state.currentClaim ?? Promise.resolve()).catch(() => {});
988
+ const waitAll = Promise.all([claimSettled, ...pendingRuns]);
989
+ const deadlineMs = opts?.stopDrainDeadlineMs ?? MAX_WEBHOOK_TIMEOUT_MS + 5000;
990
+ let timedOut = false;
991
+ let timer;
992
+ const timeout = new Promise((resolve) => {
993
+ timer = setTimeout(() => {
994
+ timedOut = true;
995
+ resolve();
996
+ }, deadlineMs);
997
+ });
998
+ await Promise.race([waitAll, timeout]);
999
+ clearTimeout(timer);
1000
+ const abandoned = timedOut ? state.inFlightRuns.size : 0;
1001
+ const drained = drainTarget - abandoned;
1002
+ logger3.info("[emitter] stopped", {
1003
+ id: emitterId,
1004
+ event: "emitter_stopped",
1005
+ drained,
1006
+ abandoned
1007
+ });
968
1008
  };
969
1009
  }
970
1010
  export {
@@ -978,5 +1018,5 @@ export {
978
1018
  startEmitter
979
1019
  };
980
1020
 
981
- //# debugId=4F50587CC3F4DDE664756E2164756E21
1021
+ //# debugId=84298FAC2C66389B64756E2164756E21
982
1022
  //# sourceMappingURL=emitter.js.map
@@ -2,7 +2,7 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/runtime/emitter.ts", "../src/runtime/formats/index.ts", "../src/runtime/formats/cloudevents.ts", "../src/runtime/formats/cloudflare.ts", "../src/runtime/formats/inngest.ts", "../src/runtime/formats/raw.ts", "../src/runtime/formats/standard-webhooks.ts", "../src/runtime/formats/trigger.ts", "../src/runtime/meter-socket.ts", "../src/runtime/webhook-state.ts", "../src/runtime/emitter-matcher.ts"],
4
4
  "sourcesContent": [
5
- "import { randomUUID } from \"node:crypto\";\nimport { lookup as dnsLookup } from \"node:dns/promises\";\nimport {\n\ttype Database,\n\ttype Webhook,\n\ttype WebhookOutbox,\n\tdescribeDbUrl,\n\tgetTargetDb,\n} from \"@secondlayer/shared/db\";\nimport { getWebhookSigningSecret } from \"@secondlayer/shared/db/queries/webhooks\";\nimport { logger } from \"@secondlayer/shared/logger\";\nimport { listen, targetListenerUrl } from \"@secondlayer/shared/queue/listener\";\nimport {\n\ttype WebhookTestResult,\n\twebhookTimeoutMsCeiling,\n} from \"@secondlayer/shared/schemas/webhooks\";\nimport { type Kysely, sql } from \"kysely\";\nimport { buildForFormat } from \"./formats/index.ts\";\nimport { recordDelivery } from \"./meter-socket.ts\";\nimport { refreshMatcher } from \"./webhook-state.ts\";\n\n/**\n * Webhook emitter — drains `webhook_outbox` and POSTs deliveries.\n *\n * Hot path: LISTEN on `webhooks:new_outbox` and `webhooks:changed`.\n * On notify, claim a batch with `FOR UPDATE SKIP LOCKED LIMIT 50`, dispatch\n * each row via HTTP, write a `webhook_deliveries` attempt row, then\n * either mark `status='delivered'` or schedule the next attempt.\n *\n * Backoff schedule (attempt → wait):\n * 0 → 30s, 1 → 2m, 2 → 10m, 3 → 1h, 4 → 6h, 5 → 24h, 6 → 72h.\n * After `max_retries` (default 7) attempts → `status='dead'`.\n *\n * Per-sub circuit breaker: 20 consecutive failures → sub flipped to\n * `paused` with `circuit_opened_at=NOW()`. Manual /resume drains backlog.\n *\n * Per-sub concurrency cap: in-memory semaphore, default 4 in-flight HTTP\n * requests per webhook. Sprint-4 adds SSRF allowlist.\n */\n\nconst BATCH_SIZE = 50;\nconst LIVE_SHARE = 0.9; // 90% of batch to non-replay, 10% to replay\nconst BACKOFF_SECONDS = [30, 120, 600, 3600, 21600, 86400, 259200];\nconst CIRCUIT_THRESHOLD = 20;\n/**\n * When a batch is claimed the outbox row's `next_attempt_at` is pushed\n * `LOCK_WINDOW_MS` into the future. Any crash between claim + settle\n * leaves the row re-claimable after this window expires — the SSOT for\n * double-dispatch prevention.\n *\n * Must exceed the maximum possible in-flight delivery time so a slow-but-alive\n * receiver's row is never re-claimed mid-delivery (duplicate dispatch). Derived\n * from the configurable timeout ceiling (`WEBHOOK_TIMEOUT_MS_CEILING`, default\n * 300_000ms — see `webhookTimeoutMsCeiling` in shared/schemas/webhooks.ts) so a\n * raised ceiling can never create a delivery window shorter than a webhook's\n * own timeout.\n */\nexport const MAX_WEBHOOK_TIMEOUT_MS: number = webhookTimeoutMsCeiling();\nexport const LOCK_WINDOW_MS: number = MAX_WEBHOOK_TIMEOUT_MS + 60_000; // ceiling + settle margin\n\n/** A sub's persistent delivery queue + in-flight counter. Lives for as long\n * as the sub has (or recently had) work — see `enqueueForSub`/`pumpSub`.\n * `sub` is refreshed on every enqueue so a status/concurrency change (a\n * pause, a resume, a config edit) is picked up by the next batch of rows\n * without waiting for anything to expire. */\ninterface SubQueue {\n\tsub: Webhook;\n\tqueue: WebhookOutbox[];\n\tinFlight: number;\n}\n\ninterface RunningState {\n\trunning: boolean;\n\t/** Per-webhook queue + concurrency counter — see `SubQueue`. Replaces a\n\t * bare in-flight counter so dispatch can be decoupled from the claim\n\t * transaction (see `claimAndDispatchOnce`): claiming a batch no longer\n\t * waits for a sub's HTTP calls to finish before the NEXT claim can run,\n\t * it just hands rows to that sub's queue and returns. */\n\tsubQueues: Map<string, SubQueue>;\n\tclaimInFlight: boolean;\n\t/** Set when a wake (NOTIFY) arrives while a claim cycle is already\n\t * running. The in-flight cycle checks this before releasing the lock —\n\t * see `claimAndDrain`'s \"drain-loop\" pass — so rows that land mid-drain\n\t * aren't stranded until an unrelated future wake or the safety poll. */\n\tclaimPending: boolean;\n}\n\nfunction nextDelaySeconds(attempt: number): number {\n\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\treturn BACKOFF_SECONDS[Math.min(attempt, BACKOFF_SECONDS.length - 1)]!;\n}\n\n// ── SSRF guard ────────────────────────────────────────────────────────\n// Block deliveries to private/loopback/link-local ranges unless\n// SECONDLAYER_ALLOW_PRIVATE_EGRESS=true (self-host + local-dev opt-in).\n\nconst PRIVATE_V4_PATTERNS = [\n\t/^127\\./, // loopback\n\t/^10\\./, // private class A\n\t/^172\\.(1[6-9]|2\\d|3[01])\\./, // private class B\n\t/^192\\.168\\./, // private class C\n\t/^169\\.254\\./, // link-local\n\t/^0\\./, // \"this\" network\n\t/^100\\.(6[4-9]|[7-9]\\d|1[01]\\d|12[0-7])\\./, // CGNAT 100.64/10\n\t/^198\\.(1[89])\\./, // benchmarking 198.18.0.0/15\n\t/^(22[4-9]|23[0-9])\\./, // multicast 224.0.0.0/4\n\t/^(24[0-9]|25[0-5])\\./, // reserved 240.0.0.0/4 (incl. 255.255.255.255 broadcast)\n];\n\n/**\n * Classify a raw IP address literal (v4 or v6, no brackets, already lowercased\n * or not) as private/loopback/link-local/reserved. Covers v4 private ranges +\n * link-local (incl. `169.254.169.254`), `0.0.0.0`, benchmarking\n * (`198.18.0.0/15`), multicast (`224.0.0.0/4`), reserved/broadcast\n * (`240.0.0.0/4`, incl. `255.255.255.255`), IPv6 loopback (`::1`), unspecified\n * (`::`), unique-local (`fc00::/7`), link-local (`fe80::/10`), IPv6 multicast\n * (`ff00::/8`), IPv4-mapped IPv6 (`::ffff:127.0.0.1`, `::ffff:7f00:0001`), and\n * NAT64 (`64:ff9b::/96`, embedded v4 classified the same as IPv4-mapped).\n *\n * Shared by `isPrivateEgress` (literal-hostname fast-fail) and the resolved-\n * DNS-address check in `checkEgressAllowed` (rebinding mitigation).\n */\nfunction isPrivateIp(address: string): boolean {\n\tconst host = address.toLowerCase();\n\n\tif (host === \"0.0.0.0\") return true;\n\tif (host === \"::\" || host === \"::1\") return true;\n\t// Unique-local (fc00::/7) + link-local (fe80::/10) + multicast (ff00::/8)\n\tif (/^f[cd][0-9a-f]{2}:/.test(host)) return true;\n\tif (/^fe[89ab][0-9a-f]:/.test(host)) return true;\n\tif (/^ff[0-9a-f]{2}:/.test(host)) return true;\n\n\t// IPv4-mapped IPv6 — `::ffff:127.0.0.1` or `::ffff:7f00:0001`\n\tconst mapped = host.match(/^::ffff:(.+)$/);\n\tif (mapped) {\n\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\tconst inner = mapped[1]!;\n\t\t// Dotted form: rerun v4 checks.\n\t\tif (/^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(inner)) {\n\t\t\tfor (const p of PRIVATE_V4_PATTERNS) if (p.test(inner)) return true;\n\t\t}\n\t\t// Hex form: 7f00:0001 → 127.0.0.1\n\t\tconst hex = inner.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);\n\t\tif (hex) {\n\t\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\t\tconst a = Number.parseInt(hex[1]!, 16);\n\t\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\t\tconst b = Number.parseInt(hex[2]!, 16);\n\t\t\tconst dotted = `${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`;\n\t\t\tfor (const p of PRIVATE_V4_PATTERNS) if (p.test(dotted)) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t// NAT64 (`64:ff9b::/96`) embeds an IPv4 address in the low 32 bits —\n\t// classify it exactly like the IPv4-mapped case above (dotted or hex).\n\tconst nat64 = host.match(/^64:ff9b::(.+)$/);\n\tif (nat64) {\n\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\tconst inner = nat64[1]!;\n\t\tif (/^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(inner)) {\n\t\t\tfor (const p of PRIVATE_V4_PATTERNS) if (p.test(inner)) return true;\n\t\t}\n\t\tconst hex = inner.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);\n\t\tif (hex) {\n\t\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\t\tconst a = Number.parseInt(hex[1]!, 16);\n\t\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\t\tconst b = Number.parseInt(hex[2]!, 16);\n\t\t\tconst dotted = `${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`;\n\t\t\tfor (const p of PRIVATE_V4_PATTERNS) if (p.test(dotted)) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tfor (const p of PRIVATE_V4_PATTERNS) {\n\t\tif (p.test(host)) return true;\n\t}\n\treturn false;\n}\n\n/**\n * Reject hostnames spelled as private IPs (cheap, synchronous, no I/O — a\n * fast-fail pre-filter). DNS-level rebinding (hostname that *resolves* to a\n * private IP at egress time) is NOT caught here — see `checkEgressAllowed`,\n * which resolves DNS and validates every returned address.\n */\nfunction isPrivateEgress(url: string): boolean {\n\tlet parsed: URL;\n\ttry {\n\t\tparsed = new URL(url);\n\t} catch {\n\t\treturn true; // malformed URL: reject\n\t}\n\tif (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n\t\treturn true;\n\t}\n\n\t// Strip brackets from IPv6 literals.\n\tconst raw = parsed.hostname.toLowerCase();\n\tconst host =\n\t\traw.startsWith(\"[\") && raw.endsWith(\"]\") ? raw.slice(1, -1) : raw;\n\n\tif (host === \"localhost\") return true;\n\treturn isPrivateIp(host);\n}\n\nfunction allowPrivateEgress(): boolean {\n\treturn process.env.SECONDLAYER_ALLOW_PRIVATE_EGRESS === \"true\";\n}\n\ninterface ResolvedAddress {\n\taddress: string;\n\tfamily: number;\n}\n\ntype DnsLookupFn = (hostname: string) => Promise<ResolvedAddress[]>;\n\nasync function defaultDnsLookup(hostname: string): Promise<ResolvedAddress[]> {\n\treturn dnsLookup(hostname, { all: true });\n}\n\nlet dnsLookupImpl: DnsLookupFn = defaultDnsLookup;\n\n/**\n * Test-only seam: inject a fake DNS resolver so tests can simulate a hostname\n * resolving to a private/metadata IP without depending on real DNS or\n * network access. Pass `null` to restore the real `dns.lookup`-backed\n * resolver. Production code never calls this — only `ssrf.test.ts` does.\n */\nexport function __setDnsLookupForTest(fn: DnsLookupFn | null): void {\n\tdnsLookupImpl = fn ?? defaultDnsLookup;\n}\n\nconst ALLOW_HINT = \"(set SECONDLAYER_ALLOW_PRIVATE_EGRESS=true to allow)\";\n\n/**\n * Full egress check: literal fast-fail, then resolve DNS and reject if *any*\n * returned address (not just the first) is private/loopback/link-local —\n * a single private answer among several is enough for an attacker to abuse.\n *\n * Residual TOCTOU: this resolves-and-validates *immediately before* `fetch()`\n * but does not pin the actual connection to the validated address. True\n * pinning (resolve once, connect to that literal IP, keep the original `Host`\n * header/SNI) was attempted per plan f053 via a custom undici `Agent` with\n * `connect.lookup` passed as `fetch()`'s `dispatcher` — the standard Node\n * ecosystem pattern for this. Empirically, under Bun 1.3.10, that hook was\n * silently never invoked (confirmed with a throwaway smoke test hitting a\n * real local HTTP server: `lookupCalled` stayed `false` and the connection\n * failed even though the dispatcher was passed) — Bun's `fetch()` does not\n * implement undici's `connect.lookup` dispatcher hook, whether reached via\n * the global `fetch` or `import { fetch } from \"undici\"` (Bun overrides\n * \"undici\"'s `fetch` export with its own native implementation regardless of\n * an installed real `undici` package). Re-tested under Bun 1.4.2 (plan 043):\n * identical result — `lookupCalled` still stays `false` via both the global\n * `fetch` and `import { fetch } from \"undici\"`, so the gap remains unfixed at\n * this version too. So a second DNS answer between this check and `fetch()`'s\n * own resolution (true rebinding, not just a one-time private answer) could\n * in theory still slip through. This closes the practical rebinding gap\n * (attacker's hostname resolves private at delivery time) without adding a\n * dependency; a network-level egress allowlist/proxy is the recommended\n * defense-in-depth for the remaining TOCTOU sliver.\n */\nexport async function checkEgressAllowed(url: string): Promise<string | null> {\n\tif (isPrivateEgress(url)) {\n\t\treturn `refused private egress: literal address ${ALLOW_HINT}`;\n\t}\n\n\tlet parsed: URL;\n\ttry {\n\t\tparsed = new URL(url);\n\t} catch {\n\t\treturn `refused private egress: malformed URL ${ALLOW_HINT}`;\n\t}\n\tif (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n\t\treturn `refused private egress: unsupported protocol ${ALLOW_HINT}`;\n\t}\n\n\tconst raw = parsed.hostname.toLowerCase();\n\tconst hostname =\n\t\traw.startsWith(\"[\") && raw.endsWith(\"]\") ? raw.slice(1, -1) : raw;\n\n\tlet resolved: ResolvedAddress[];\n\ttry {\n\t\tresolved = await dnsLookupImpl(hostname);\n\t} catch (err) {\n\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\treturn `refused private egress: DNS resolution failed (${msg}) ${ALLOW_HINT}`;\n\t}\n\tif (resolved.length === 0) {\n\t\treturn `refused private egress: DNS resolution returned no addresses ${ALLOW_HINT}`;\n\t}\n\tfor (const { address } of resolved) {\n\t\tif (isPrivateIp(address)) {\n\t\t\treturn `refused private egress: hostname resolves to private address ${address} ${ALLOW_HINT}`;\n\t\t}\n\t}\n\treturn null;\n}\n\n/** The wire result of one POST attempt (no DB side effect) — shared by the\n * emitter hot path and the `deliverTestEvent` test path. */\ninterface PostResult {\n\tok: boolean;\n\tstatusCode: number | null;\n\terror: string | null;\n\tdurationMs: number;\n\tresponseBody: string | null;\n\tresponseHeaders: Record<string, string> | null;\n}\n\n/**\n * Bound on redirect hops honored while delivering a webhook. `fetch` is\n * called with `redirect: \"manual\"` so a 3xx never auto-follows — each hop's\n * target is re-validated through `checkEgressAllowed` before it is fetched\n * (see `postToWebhook`), otherwise a `Location` header pointing at a\n * private/metadata address would bypass the egress guard entirely. Counts\n * every fetch issued (the initial request plus each redirect), so this is\n * also the max number of `fetch` calls one delivery attempt can make.\n */\nexport const MAX_REDIRECT_HOPS: number = 3;\n\n/** POST a pre-built body to a webhook URL with the SSRF guard + timeout.\n * Pure transport: returns the attempt result; the caller logs the delivery row.\n * Follows redirects manually (up to `MAX_REDIRECT_HOPS`), re-running the\n * egress guard against every hop's target — the guard only protects the\n * first request otherwise, and a webhook target can redirect to a private/\n * metadata address to read back the response after f053 pinned the guard to\n * just the original URL. */\nasync function postToWebhook(\n\turl: string,\n\tbody: string,\n\theaders: Record<string, string>,\n\ttimeoutMs: number,\n): Promise<PostResult> {\n\tconst start = performance.now();\n\tlet target = url;\n\tlet statusCode: number | null = null;\n\tlet error: string | null = null;\n\tlet ok = false;\n\tlet responseBody = \"\";\n\tlet responseHeaders: Record<string, string> | null = null;\n\ttry {\n\t\tfor (let hop = 0; ; hop++) {\n\t\t\tif (!allowPrivateEgress()) {\n\t\t\t\tconst refusal = await checkEgressAllowed(target);\n\t\t\t\tif (refusal) {\n\t\t\t\t\tlogger.warn(\"[emitter] refused private egress\", {\n\t\t\t\t\t\turl: target,\n\t\t\t\t\t\treason: refusal,\n\t\t\t\t\t});\n\t\t\t\t\treturn {\n\t\t\t\t\t\tok: false,\n\t\t\t\t\t\tstatusCode: null,\n\t\t\t\t\t\terror: refusal,\n\t\t\t\t\t\tdurationMs: Math.round(performance.now() - start),\n\t\t\t\t\t\tresponseBody: null,\n\t\t\t\t\t\tresponseHeaders: null,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (hop >= MAX_REDIRECT_HOPS) {\n\t\t\t\treturn {\n\t\t\t\t\tok: false,\n\t\t\t\t\tstatusCode,\n\t\t\t\t\terror: `too many redirects (exceeded ${MAX_REDIRECT_HOPS} hops)`,\n\t\t\t\t\tdurationMs: Math.round(performance.now() - start),\n\t\t\t\t\tresponseBody: null,\n\t\t\t\t\tresponseHeaders: null,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst res = await fetch(target, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders,\n\t\t\t\tbody,\n\t\t\t\tredirect: \"manual\",\n\t\t\t\tsignal: AbortSignal.timeout(timeoutMs),\n\t\t\t});\n\t\t\tstatusCode = res.status;\n\n\t\t\tconst location = res.headers.get(\"location\");\n\t\t\tif (res.status >= 300 && res.status < 400 && location) {\n\t\t\t\ttarget = new URL(location, target).toString();\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tok = res.ok;\n\t\t\t// Collect small response preview for the delivery log (≤8KB).\n\t\t\tconst buf = await res.arrayBuffer();\n\t\t\tconst truncated = buf.byteLength > 8192 ? buf.slice(0, 8192) : buf;\n\t\t\tresponseBody = Buffer.from(truncated).toString(\"utf8\");\n\t\t\tresponseHeaders = Object.fromEntries(res.headers.entries());\n\t\t\tbreak;\n\t\t}\n\t} catch (err) {\n\t\terror = err instanceof Error ? err.message : String(err);\n\t}\n\treturn {\n\t\tok,\n\t\tstatusCode,\n\t\terror,\n\t\tdurationMs: Math.round(performance.now() - start),\n\t\tresponseBody: responseBody || null,\n\t\tresponseHeaders,\n\t};\n}\n\n/**\n * Test-only seam: exposes `postToWebhook` (otherwise module-private) so\n * redirect/egress behavior can be exercised directly against a stubbed\n * `fetch` + injected DNS lookup, without standing up the DB-backed emitter\n * loop. Production code never calls this — only tests do.\n */\nexport const __postToWebhookForTest: (\n\turl: string,\n\tbody: string,\n\theaders: Record<string, string>,\n\ttimeoutMs: number,\n) => Promise<PostResult> = postToWebhook;\n\nasync function dispatchOne(\n\tdb: Kysely<Database>,\n\toutboxRow: WebhookOutbox,\n\tsub: Webhook,\n): Promise<{\n\tok: boolean;\n\tstatusCode: number | null;\n\terror: string | null;\n\tdurationMs: number;\n}> {\n\tconst { body, headers } = buildForFormat(\n\t\toutboxRow,\n\t\tsub,\n\t\tgetWebhookSigningSecret(sub),\n\t);\n\tconst r = await postToWebhook(sub.url, body, headers, sub.timeout_ms);\n\n\tconst attempt = outboxRow.attempt + 1;\n\tawait db\n\t\t.insertInto(\"webhook_deliveries\")\n\t\t.values({\n\t\t\toutbox_id: outboxRow.id,\n\t\t\twebhook_id: outboxRow.webhook_id,\n\t\t\tattempt,\n\t\t\tstatus_code: r.statusCode,\n\t\t\tresponse_headers: r.responseHeaders,\n\t\t\tresponse_body: r.responseBody,\n\t\t\terror_message: r.error,\n\t\t\tduration_ms: r.durationMs,\n\t\t})\n\t\t.execute();\n\n\treturn {\n\t\tok: r.ok,\n\t\tstatusCode: r.statusCode,\n\t\terror: r.error,\n\t\tdurationMs: r.durationMs,\n\t};\n}\n\n/** A representative (non-persisted) outbox row for a test delivery, shaped to the\n * webhook's kind so `buildForFormat` produces a realistic body. */\nfunction buildTestOutboxRow(sub: Webhook): WebhookOutbox {\n\tconst now = new Date();\n\treturn {\n\t\tid: randomUUID(),\n\t\twebhook_id: sub.id,\n\t\tkind: sub.kind,\n\t\tsubgraph_name: sub.subgraph_name ?? null,\n\t\ttable_name: sub.table_name ?? null,\n\t\tblock_height: 0,\n\t\ttx_id: null,\n\t\trow_pk: null,\n\t\tevent_type:\n\t\t\tsub.kind === \"chain\"\n\t\t\t\t? \"chain.test.apply\"\n\t\t\t\t: `${sub.subgraph_name ?? \"subgraph\"}.${sub.table_name ?? \"test\"}.created`,\n\t\tpayload: {\n\t\t\ttest: true,\n\t\t\tmessage: \"Secondlayer test delivery\",\n\t\t\twebhook_id: sub.id,\n\t\t\tsent_at: now.toISOString(),\n\t\t},\n\t\tdedup_key: `test:${sub.id}:${now.getTime()}`,\n\t\tattempt: 0,\n\t\tnext_attempt_at: now,\n\t\tstatus: \"pending\",\n\t\tis_replay: false,\n\t\tblock_time: null,\n\t\tdelivered_at: null,\n\t\tfailed_at: null,\n\t\tlocked_by: null,\n\t\tlocked_until: null,\n\t\tlast_error: null,\n\t\tcreated_at: now,\n\t};\n}\n\n/**\n * Build a representative webhook for `sub`'s configured format, POST it (same\n * SSRF guard + timeout + signing as a real delivery), and log a delivery row\n * with a null `outbox_id` so it appears under the webhook's deliveries\n * without being tied to a queued event. Powers `POST /:id/test`.\n */\nexport async function deliverTestEvent(\n\tdb: Kysely<Database>,\n\tsub: Webhook,\n): Promise<WebhookTestResult> {\n\tconst testRow = buildTestOutboxRow(sub);\n\tconst { body, headers } = buildForFormat(\n\t\ttestRow,\n\t\tsub,\n\t\tgetWebhookSigningSecret(sub),\n\t);\n\tconst r = await postToWebhook(sub.url, body, headers, sub.timeout_ms);\n\tconst inserted = await db\n\t\t.insertInto(\"webhook_deliveries\")\n\t\t.values({\n\t\t\toutbox_id: null,\n\t\t\twebhook_id: sub.id,\n\t\t\tattempt: 1,\n\t\t\tstatus_code: r.statusCode,\n\t\t\tresponse_headers: r.responseHeaders,\n\t\t\tresponse_body: r.responseBody,\n\t\t\terror_message: r.error,\n\t\t\tduration_ms: r.durationMs,\n\t\t})\n\t\t.returning(\"id\")\n\t\t.executeTakeFirstOrThrow();\n\treturn {\n\t\tok: r.ok,\n\t\tstatusCode: r.statusCode,\n\t\terror: r.error,\n\t\tdurationMs: r.durationMs,\n\t\tdeliveryId: inserted.id,\n\t};\n}\n\nasync function settleDelivered(\n\tdb: Kysely<Database>,\n\toutboxRow: WebhookOutbox,\n): Promise<void> {\n\tawait db.transaction().execute(async (tx) => {\n\t\tconst result = await tx\n\t\t\t.updateTable(\"webhook_outbox\")\n\t\t\t.set({\n\t\t\t\tstatus: \"delivered\",\n\t\t\t\tdelivered_at: new Date(),\n\t\t\t\tattempt: outboxRow.attempt + 1,\n\t\t\t\tlocked_by: null,\n\t\t\t\tlocked_until: null,\n\t\t\t})\n\t\t\t// Guard against a reorg that marked this row `dead` (orphaned) while\n\t\t\t// the POST was in flight — settling a delivery outcome must never\n\t\t\t// resurrect a row the reorg already decided is gone. `status='pending'`\n\t\t\t// is the only state a claimed row can settle from.\n\t\t\t.where(\"id\", \"=\", outboxRow.id)\n\t\t\t.where(\"status\", \"=\", \"pending\")\n\t\t\t.executeTakeFirst();\n\t\tif (Number(result.numUpdatedRows ?? 0) === 0) {\n\t\t\tlogger.info(\"webhook.settle.orphaned\", {\n\t\t\t\toutboxId: outboxRow.id,\n\t\t\t\twebhookId: outboxRow.webhook_id,\n\t\t\t\toutcome: \"delivered\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\t\tawait tx\n\t\t\t.updateTable(\"webhooks\")\n\t\t\t.set({\n\t\t\t\tlast_delivery_at: new Date(),\n\t\t\t\tlast_success_at: new Date(),\n\t\t\t\tcircuit_failures: 0,\n\t\t\t\tlast_error: null,\n\t\t\t\tupdated_at: new Date(),\n\t\t\t})\n\t\t\t.where(\"id\", \"=\", outboxRow.webhook_id)\n\t\t\t.execute();\n\n\t\t// Hosted-stack event meter (plan 044, step 5): counts a real delivery,\n\t\t// never a retry (this function only runs on the successful outcome) and\n\t\t// never a test delivery (`deliverTestEvent` never calls this). A no-op\n\t\t// on self-host (WEBHOOK_METER_SOCKET unset).\n\t\trecordDelivery();\n\t});\n}\n\nasync function settleFailed(\n\tdb: Kysely<Database>,\n\toutboxRow: WebhookOutbox,\n\tsub: Webhook,\n\terrText: string,\n): Promise<void> {\n\tconst attempt = outboxRow.attempt + 1;\n\tconst isDead = attempt >= sub.max_retries;\n\tconst nextAt = isDead\n\t\t? null\n\t\t: new Date(Date.now() + nextDelaySeconds(outboxRow.attempt) * 1000);\n\n\tawait db.transaction().execute(async (tx) => {\n\t\tconst result = await tx\n\t\t\t.updateTable(\"webhook_outbox\")\n\t\t\t.set({\n\t\t\t\tattempt,\n\t\t\t\tnext_attempt_at: nextAt ?? new Date(),\n\t\t\t\tstatus: isDead ? \"dead\" : \"pending\",\n\t\t\t\tfailed_at: isDead ? new Date() : null,\n\t\t\t\tlocked_by: null,\n\t\t\t\tlocked_until: null,\n\t\t\t})\n\t\t\t// Guard against a reorg that marked this row `dead` (orphaned) while\n\t\t\t// the POST was in flight — see the matching guard in `settleDelivered`.\n\t\t\t.where(\"id\", \"=\", outboxRow.id)\n\t\t\t.where(\"status\", \"=\", \"pending\")\n\t\t\t.executeTakeFirst();\n\t\tif (Number(result.numUpdatedRows ?? 0) === 0) {\n\t\t\tlogger.info(\"webhook.settle.orphaned\", {\n\t\t\t\toutboxId: outboxRow.id,\n\t\t\t\twebhookId: outboxRow.webhook_id,\n\t\t\t\toutcome: \"failed\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\t// Atomic increment — concurrent failures must not clobber each other.\n\t\t// `RETURNING circuit_failures` gives us the post-increment value to\n\t\t// decide whether this failure tripped the circuit.\n\t\tconst incResult = await sql<{ circuit_failures: number }>`\n\t\t\tUPDATE webhooks\n\t\t\tSET circuit_failures = circuit_failures + 1,\n\t\t\t\tlast_delivery_at = NOW(),\n\t\t\t\tlast_error = ${errText.slice(0, 500)},\n\t\t\t\tupdated_at = NOW()\n\t\t\tWHERE id = ${sub.id}\n\t\t\tRETURNING circuit_failures\n\t\t`.execute(tx);\n\t\tconst newFailures =\n\t\t\tincResult.rows[0]?.circuit_failures ?? sub.circuit_failures + 1;\n\t\tconst shouldTripCircuit = newFailures >= CIRCUIT_THRESHOLD;\n\n\t\tif (shouldTripCircuit) {\n\t\t\t// Transition to paused only on the first failure that crossed\n\t\t\t// the threshold — additional failures in-flight harmlessly\n\t\t\t// re-set the same fields.\n\t\t\tawait tx\n\t\t\t\t.updateTable(\"webhooks\")\n\t\t\t\t.set({\n\t\t\t\t\tstatus: \"paused\",\n\t\t\t\t\tcircuit_opened_at: new Date(),\n\t\t\t\t\tupdated_at: new Date(),\n\t\t\t\t})\n\t\t\t\t.where(\"id\", \"=\", sub.id)\n\t\t\t\t.execute();\n\t\t\tlogger.warn(\n\t\t\t\t\"Webhook circuit tripped — paused after consecutive failures\",\n\t\t\t\t{\n\t\t\t\t\twebhook: sub.name,\n\t\t\t\t\tfailures: newFailures,\n\t\t\t\t},\n\t\t\t);\n\t\t}\n\t});\n}\n\n/** What woke this claim cycle — purely for the diagnostic log below; the\n * claim/dispatch logic itself doesn't branch on it. `\"drain-loop\"` marks a\n * pass this function re-ran on itself (see `claimAndDrain`) because a wake\n * arrived (or a per-lane batch cap was hit) while the previous pass was\n * still claiming. `\"changed\"` is a resume (or any webhook edit) — see the\n * `webhooks:changed` listener. */\ntype ClaimTrigger = \"notify\" | \"poll\" | \"startup\" | \"changed\" | \"drain-loop\";\n\n/**\n * The regression this closes: a NOTIFY that arrives while a claim cycle is\n * ALREADY running used to be dropped on the floor (the `claimInFlight` guard\n * below just returned 0), and nothing re-checked once that cycle finished.\n * Rows inserted mid-cycle — a normal burst of ~6 chain-webhook matches per\n * block — sat `pending` until an UNRELATED future wake happened to fire, or\n * the 2-minute safety poll.\n *\n * The holder of `claimInFlight` now keeps looping (`claimPending`, set by any\n * caller that finds the lock held) until a pass claims nothing NEW and no one\n * asked for a recheck while it ran — so a burst drains fully off one wake\n * instead of needing one wake per claim-sized chunk. Critically, this lock\n * wraps ONLY `claimAndDispatchOnce`'s DB claim query, never dispatch (see its\n * doc) — a slow or hanging receiver must never hold up the next claim.\n */\nasync function claimAndDrain(\n\tdb: Kysely<Database>,\n\tstate: RunningState,\n\temitterId: string,\n\ttrigger: ClaimTrigger,\n): Promise<number> {\n\tif (state.claimInFlight) {\n\t\tstate.claimPending = true;\n\t\treturn 0;\n\t}\n\tstate.claimInFlight = true;\n\tlet totalClaimed = 0;\n\ttry {\n\t\tlet currentTrigger = trigger;\n\t\tfor (;;) {\n\t\t\tstate.claimPending = false;\n\t\t\tconst result = await claimAndDispatchOnce(\n\t\t\t\tdb,\n\t\t\t\tstate,\n\t\t\t\temitterId,\n\t\t\t\tcurrentTrigger,\n\t\t\t);\n\t\t\ttotalClaimed += result.claimed;\n\t\t\t// Keep going while either: a wake landed mid-pass (claimPending —\n\t\t\t// set by the guard above, from another caller), or either lane\n\t\t\t// (live/replay) came back full, meaning more may be waiting behind\n\t\t\t// it. Comparing against the LANE limits, not the combined batch\n\t\t\t// size, matters: live traffic alone is capped at `liveLimit` (90%\n\t\t\t// of BATCH_SIZE) and would never reach the full BATCH_SIZE on its\n\t\t\t// own, so that comparison never re-looped on a live-only backlog.\n\t\t\tif (!state.claimPending && !result.mayHaveMore) break;\n\t\t\tcurrentTrigger = \"drain-loop\";\n\t\t}\n\t} finally {\n\t\tstate.claimInFlight = false;\n\t}\n\treturn totalClaimed;\n}\n\ninterface ClaimResult {\n\tclaimed: number;\n\tdispatched: number;\n\treleased: number;\n\tmayHaveMore: boolean;\n}\n\n/**\n * One claim pass: SELECT a batch, mark it locked, then hand each sub's slice\n * to its persistent queue and return — WITHOUT waiting for any of it to\n * dispatch. Caller (`claimAndDrain`) owns the re-loop and the `claimInFlight`\n * lock, which now covers only this function's DB work.\n *\n * This decoupling is the fix for the emitter-wide latency regression: the\n * previous version awaited `Promise.all(subIds.map(drainForSub))` — every\n * sub's FULL dispatch — before returning, so the slowest receiver in a batch\n * (a hung connection, a multi-second response) held the process-wide claim\n * lock for as long as IT took, starving every other sub's claims (including\n * a fast sub's own fresh rows) for that whole time. Claiming is now a single\n * short transaction; delivery happens on each sub's own independent\n * queue/semaphore (`enqueueForSub`/`pumpSub`), so one sub's pace never\n * gates another's.\n *\n * Also fixes the \"stuck rows\" bug: a row claimed for a sub that turns out to\n * be `paused` (status read fresh here, after the claim) is released\n * IMMEDIATELY — `next_attempt_at` reset to now, lock cleared — instead of\n * being silently abandoned with its lock held for the full `LOCK_WINDOW_MS`.\n * The old code's `if (sub.status !== \"active\") return;` left the row\n * claimed-but-untouched: locked, `next_attempt_at` pushed far into the\n * future, never dispatched, never settled — a resumed webhook's backlog\n * then only drained once each row's stale lock happened to expire.\n */\nasync function claimAndDispatchOnce(\n\tdb: Kysely<Database>,\n\tstate: RunningState,\n\temitterId: string,\n\ttrigger: ClaimTrigger,\n): Promise<ClaimResult> {\n\t// FOR UPDATE SKIP LOCKED — multiple emitters split the batch.\n\t// 90/10 live vs replay so a big replay doesn't starve live emits.\n\tconst liveLimit = Math.max(1, Math.round(BATCH_SIZE * LIVE_SHARE));\n\tconst replayLimit = BATCH_SIZE - liveLimit;\n\tconst { combined, liveCount, replayCount } = await db\n\t\t.transaction()\n\t\t.execute(async (tx) => {\n\t\t\tconst live = await sql<WebhookOutbox>`\n\t\t\t\t\tSELECT * FROM webhook_outbox\n\t\t\t\t\tWHERE status = 'pending'\n\t\t\t\t\t\tAND next_attempt_at <= NOW()\n\t\t\t\t\t\tAND is_replay = FALSE\n\t\t\t\t\tORDER BY next_attempt_at ASC\n\t\t\t\t\tFOR UPDATE SKIP LOCKED\n\t\t\t\t\tLIMIT ${sql.lit(liveLimit)}\n\t\t\t\t`.execute(tx);\n\t\t\tconst replay = await sql<WebhookOutbox>`\n\t\t\t\t\tSELECT * FROM webhook_outbox\n\t\t\t\t\tWHERE status = 'pending'\n\t\t\t\t\t\tAND next_attempt_at <= NOW()\n\t\t\t\t\t\tAND is_replay = TRUE\n\t\t\t\t\tORDER BY next_attempt_at ASC\n\t\t\t\t\tFOR UPDATE SKIP LOCKED\n\t\t\t\t\tLIMIT ${sql.lit(replayLimit)}\n\t\t\t\t`.execute(tx);\n\n\t\t\tconst rows = [...live.rows, ...replay.rows];\n\t\t\tif (rows.length === 0) {\n\t\t\t\treturn { combined: rows, liveCount: 0, replayCount: 0 };\n\t\t\t}\n\n\t\t\t// Push `next_attempt_at` forward by the lock window. This is\n\t\t\t// the only defense against double-dispatch if the emitter\n\t\t\t// process crashes mid-HTTP-call: the row won't be re-claimable\n\t\t\t// until `LOCK_WINDOW_MS` elapses, giving us a stale-lock\n\t\t\t// recovery window. `settleDelivered`/`settleFailed` overrides\n\t\t\t// this on the success/failure path (and the paused-sub release\n\t\t\t// below overrides it on the \"never got dispatched\" path).\n\t\t\tconst now = new Date();\n\t\t\tconst lockUntil = new Date(now.getTime() + LOCK_WINDOW_MS);\n\t\t\tawait tx\n\t\t\t\t.updateTable(\"webhook_outbox\")\n\t\t\t\t.set({\n\t\t\t\t\tlocked_by: emitterId,\n\t\t\t\t\tlocked_until: lockUntil,\n\t\t\t\t\tnext_attempt_at: lockUntil,\n\t\t\t\t})\n\t\t\t\t.where(\n\t\t\t\t\t\"id\",\n\t\t\t\t\t\"in\",\n\t\t\t\t\trows.map((r) => r.id),\n\t\t\t\t)\n\t\t\t\t.execute();\n\t\t\treturn {\n\t\t\t\tcombined: rows,\n\t\t\t\tliveCount: live.rows.length,\n\t\t\t\treplayCount: replay.rows.length,\n\t\t\t};\n\t\t});\n\n\tif (combined.length === 0) {\n\t\treturn { claimed: 0, dispatched: 0, released: 0, mayHaveMore: false };\n\t}\n\n\tconst bySubId = new Map<string, WebhookOutbox[]>();\n\tfor (const row of combined) {\n\t\tconst arr = bySubId.get(row.webhook_id);\n\t\tif (arr) arr.push(row);\n\t\telse bySubId.set(row.webhook_id, [row]);\n\t}\n\n\tconst subIds = Array.from(bySubId.keys());\n\tconst subs = await db\n\t\t.selectFrom(\"webhooks\")\n\t\t.selectAll()\n\t\t.where(\"id\", \"in\", subIds)\n\t\t.execute();\n\tconst subById = new Map(subs.map((s) => [s.id, s]));\n\n\tlet dispatched = 0;\n\tlet released = 0;\n\tconst releasedIds: string[] = [];\n\tfor (const subId of subIds) {\n\t\tconst sub = subById.get(subId);\n\t\t// biome-ignore lint/style/noNonNullAssertion: key came from bySubId itself\n\t\tconst rows = bySubId.get(subId)!;\n\t\tif (!sub || sub.status !== \"active\") {\n\t\t\treleased += rows.length;\n\t\t\treleasedIds.push(...rows.map((r) => r.id));\n\t\t\tcontinue;\n\t\t}\n\t\tdispatched += rows.length;\n\t\tenqueueForSub(db, state, sub, rows);\n\t}\n\n\tif (releasedIds.length > 0) {\n\t\t// Claimable again immediately: a webhook that's already resumed by the\n\t\t// time we notice the release loses nothing waiting for LOCK_WINDOW_MS\n\t\t// (up to several minutes) to expire on its own.\n\t\tawait db\n\t\t\t.updateTable(\"webhook_outbox\")\n\t\t\t.set({ next_attempt_at: new Date(), locked_by: null, locked_until: null })\n\t\t\t.where(\"id\", \"in\", releasedIds)\n\t\t\t.execute();\n\t}\n\n\t// One line per claim pass: what woke it, how much it found, how it split\n\t// between dispatched-now and released-back (paused), how stale the oldest\n\t// row was, and how loaded the in-flight pool already was — everything\n\t// needed to tell \"briefly busy\" apart from \"actually stuck\" without\n\t// reproducing it live. `in_flight`/`concurrency` sum across only the subs\n\t// THIS pass touched (a quiet sub's unrelated cap doesn't dilute the\n\t// reading).\n\tconst oldestCreatedAtMs = Math.min(\n\t\t...combined.map((r) => new Date(r.created_at).getTime()),\n\t);\n\tconst inFlight = subIds.reduce(\n\t\t(sum, id) => sum + (state.subQueues.get(id)?.inFlight ?? 0),\n\t\t0,\n\t);\n\tconst concurrency = subIds.reduce(\n\t\t(sum, id) => sum + (subById.get(id)?.concurrency || 4),\n\t\t0,\n\t);\n\tlogger.info(\"emitter claim cycle\", {\n\t\tevent: \"emitter_claim\",\n\t\ttrigger,\n\t\tclaimed: combined.length,\n\t\tdispatched,\n\t\treleased,\n\t\toldest_created_at_age_ms: Date.now() - oldestCreatedAtMs,\n\t\tin_flight: inFlight,\n\t\tconcurrency,\n\t});\n\n\treturn {\n\t\tclaimed: combined.length,\n\t\tdispatched,\n\t\treleased,\n\t\tmayHaveMore: liveCount >= liveLimit || replayCount >= replayLimit,\n\t};\n}\n\n/**\n * Hand `rows` to `sub`'s persistent queue and try to start dispatching\n * immediately. Decoupled from the claim transaction on purpose (see\n * `claimAndDispatchOnce`'s doc) — a slow or hanging sub's in-flight HTTP\n * calls must never hold up the next claim, for this sub or any other.\n *\n * `sub` is refreshed here on every call (not just on first creation) so a\n * status/concurrency change since the last batch — most importantly a\n * pause — is picked up by `pumpSub` without waiting for anything to expire.\n */\nfunction enqueueForSub(\n\tdb: Kysely<Database>,\n\tstate: RunningState,\n\tsub: Webhook,\n\trows: WebhookOutbox[],\n): void {\n\tlet sq = state.subQueues.get(sub.id);\n\tif (!sq) {\n\t\tsq = { sub, queue: [], inFlight: 0 };\n\t\tstate.subQueues.set(sub.id, sq);\n\t} else {\n\t\tsq.sub = sub;\n\t}\n\tsq.queue.push(...rows);\n\tpumpSub(db, state, sub.id);\n}\n\n/**\n * Start as many workers as the sub's remaining concurrency allows, pulling\n * from its queue. Reentrant-safe without an explicit lock: every branch here\n * is synchronous (the only `await` lives in `runOne`, called but not\n * awaited), so JS's single-threaded execution means two calls — one from\n * `enqueueForSub`, one from a just-finished worker's `finally` — can never\n * interleave mid-loop; each runs to completion before the other starts.\n */\nfunction pumpSub(\n\tdb: Kysely<Database>,\n\tstate: RunningState,\n\tsubId: string,\n): void {\n\tconst sq = state.subQueues.get(subId);\n\tif (!sq) return;\n\tconst cap = sq.sub.concurrency || 4;\n\twhile (\n\t\tstate.running &&\n\t\tsq.sub.status === \"active\" &&\n\t\tsq.inFlight < cap &&\n\t\tsq.queue.length > 0\n\t) {\n\t\tconst row = sq.queue.shift();\n\t\tif (!row) break;\n\t\tsq.inFlight++;\n\t\tvoid runOne(db, state, sq, row);\n\t}\n}\n\n/** Dispatch one claimed row end to end (POST, settle), then release its\n * in-flight slot and try to pull more work for the same sub. */\nasync function runOne(\n\tdb: Kysely<Database>,\n\tstate: RunningState,\n\tsq: SubQueue,\n\trow: WebhookOutbox,\n): Promise<void> {\n\ttry {\n\t\tconst result = await dispatchOne(db, row, sq.sub);\n\t\tif (result.ok) {\n\t\t\tawait settleDelivered(db, row);\n\t\t} else {\n\t\t\tconst err = result.error ?? `HTTP ${result.statusCode ?? \"?\"}`;\n\t\t\tawait settleFailed(db, row, sq.sub, err);\n\t\t}\n\t} catch (err) {\n\t\tlogger.error(\"Emitter dispatch crashed\", {\n\t\t\toutboxId: row.id,\n\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t});\n\t\tawait settleFailed(\n\t\t\tdb,\n\t\t\trow,\n\t\t\tsq.sub,\n\t\t\terr instanceof Error ? err.message : String(err),\n\t\t);\n\t} finally {\n\t\tsq.inFlight--;\n\t\tpumpSub(db, state, sq.sub.id);\n\t}\n}\n\nexport interface StartEmitterOptions {\n\t/** Interval for the background poll (ms). Defaults to 2 minutes. */\n\tpollIntervalMs?: number;\n\t/** Retention sweep interval (ms). Defaults to 1 hour. */\n\tretentionIntervalMs?: number;\n}\n\nasync function runRetention(db: Kysely<Database>): Promise<void> {\n\t// delivered outbox >7d, deliveries >30d, dead outbox >90d\n\tawait sql`\n\t\tDELETE FROM webhook_outbox\n\t\tWHERE status = 'delivered' AND delivered_at < NOW() - interval '7 days'\n\t`.execute(db);\n\tawait sql`\n\t\tDELETE FROM webhook_deliveries\n\t\tWHERE dispatched_at < NOW() - interval '30 days'\n\t`.execute(db);\n\tawait sql`\n\t\tDELETE FROM webhook_outbox\n\t\tWHERE status = 'dead' AND failed_at < NOW() - interval '90 days'\n\t`.execute(db);\n}\n\nexport async function startEmitter(\n\topts?: StartEmitterOptions,\n): Promise<() => Promise<void>> {\n\tconst emitterId = `emitter-${Math.random().toString(36).slice(2, 10)}`;\n\tconst db = getTargetDb();\n\tconst state: RunningState = {\n\t\trunning: true,\n\t\tsubQueues: new Map(),\n\t\tclaimInFlight: false,\n\t\tclaimPending: false,\n\t};\n\tconst pollIntervalMs = opts?.pollIntervalMs ?? 120_000;\n\tconst retentionIntervalMs = opts?.retentionIntervalMs ?? 60 * 60_000;\n\n\tlogger.info(\"[emitter] started\", { id: emitterId });\n\n\t// Bootstrap matcher from active subs. Retry with backoff — if this\n\t// stays broken, fail loud rather than run with an empty matcher (which\n\t// would silently drop every block's outbox emissions until the next\n\t// webhook CRUD fired `webhooks:changed`).\n\tconst MATCHER_BOOT_ATTEMPTS = 5;\n\tlet lastErr: unknown = null;\n\tfor (let i = 0; i < MATCHER_BOOT_ATTEMPTS; i++) {\n\t\ttry {\n\t\t\tawait refreshMatcher(db);\n\t\t\tlastErr = null;\n\t\t\tbreak;\n\t\t} catch (err) {\n\t\t\tlastErr = err;\n\t\t\tconst delayMs = 500 * 2 ** i; // 500ms, 1s, 2s, 4s, 8s\n\t\t\tlogger.warn(\"[emitter] matcher refresh failed, retrying\", {\n\t\t\t\tattempt: i + 1,\n\t\t\t\tdelayMs,\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\tawait new Promise((r) => setTimeout(r, delayMs));\n\t\t}\n\t}\n\tif (lastErr) {\n\t\tthrow new Error(\n\t\t\t`[emitter] matcher refresh failed ${MATCHER_BOOT_ATTEMPTS}×; aborting boot: ${\n\t\t\t\tlastErr instanceof Error ? lastErr.message : String(lastErr)\n\t\t\t}`,\n\t\t);\n\t}\n\n\t// LISTEN on new outbox + sub changes. Both channels fire on the TARGET DB\n\t// (webhook_outbox + webhooks are control-plane tables), so bind the\n\t// listener there — under the split it is NOT `DATABASE_URL`.\n\tconst listenUrl = targetListenerUrl();\n\tconst stopNew = await listen(\n\t\t\"webhooks:new_outbox\",\n\t\t() => {\n\t\t\tif (!state.running) return;\n\t\t\tvoid claimAndDrain(db, state, emitterId, \"notify\").catch((err) =>\n\t\t\t\tlogger.error(\"[emitter] claim failed\", {\n\t\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t\t}),\n\t\t\t);\n\t\t},\n\t\t{ connectionString: listenUrl },\n\t);\n\tconst stopChanged = await listen(\n\t\t\"webhooks:changed\",\n\t\t() => {\n\t\t\tif (!state.running) return;\n\t\t\tvoid refreshMatcher(db).catch((err) =>\n\t\t\t\tlogger.error(\"[emitter] matcher refresh failed\", {\n\t\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t\t}),\n\t\t\t);\n\t\t\t// A resume is exactly `webhooks:changed` — its rows were already\n\t\t\t// released back to claimable (see claimAndDispatchOnce's paused-sub\n\t\t\t// branch) as soon as we noticed the pause, so a resumed webhook's\n\t\t\t// backlog drains on the SAME event that flips it active again,\n\t\t\t// instead of waiting for an unrelated future notify or the poll.\n\t\t\tvoid claimAndDrain(db, state, emitterId, \"changed\").catch((err) =>\n\t\t\t\tlogger.error(\"[emitter] claim failed\", {\n\t\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t\t}),\n\t\t\t);\n\t\t},\n\t\t{ connectionString: listenUrl },\n\t);\n\t// Names both channels + the exact host/db LISTENed on (no credentials) so\n\t// a split-DB misconfiguration — the wake never reaching this process — is\n\t// visible in `docker logs` at boot, not only inferred from a delivery\n\t// latency graph later.\n\tlogger.info(\"[emitter] wake listeners connected\", {\n\t\tchannels: [\"webhooks:new_outbox\", \"webhooks:changed\"],\n\t\tdb: describeDbUrl(listenUrl),\n\t});\n\n\t// Poll every pollIntervalMs as a safety net for missed notifications +\n\t// backoff wakeups (rows whose next_attempt_at has passed).\n\tconst poll = setInterval(() => {\n\t\tif (!state.running) return;\n\t\tvoid claimAndDrain(db, state, emitterId, \"poll\").catch((err) =>\n\t\t\tlogger.error(\"[emitter] poll claim failed\", {\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t}),\n\t\t);\n\t}, pollIntervalMs);\n\n\t// Kick once on startup so any rows that arrived before we started drain.\n\tvoid claimAndDrain(db, state, emitterId, \"startup\");\n\n\t// Retention sweep — hourly by default.\n\tconst retention = setInterval(() => {\n\t\tif (!state.running) return;\n\t\tvoid runRetention(db).catch((err) =>\n\t\t\tlogger.error(\"[emitter] retention failed\", {\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t}),\n\t\t);\n\t}, retentionIntervalMs);\n\n\treturn async () => {\n\t\tstate.running = false;\n\t\tclearInterval(poll);\n\t\tclearInterval(retention);\n\t\tawait stopNew();\n\t\tawait stopChanged();\n\t\tlogger.info(\"[emitter] stopped\", { id: emitterId });\n\t};\n}\n",
5
+ "import { randomUUID } from \"node:crypto\";\nimport { lookup as dnsLookup } from \"node:dns/promises\";\nimport {\n\ttype Database,\n\ttype Webhook,\n\ttype WebhookOutbox,\n\tdescribeDbUrl,\n\tgetTargetDb,\n} from \"@secondlayer/shared/db\";\nimport { getWebhookSigningSecret } from \"@secondlayer/shared/db/queries/webhooks\";\nimport { logger } from \"@secondlayer/shared/logger\";\nimport { listen, targetListenerUrl } from \"@secondlayer/shared/queue/listener\";\nimport {\n\ttype WebhookTestResult,\n\twebhookTimeoutMsCeiling,\n} from \"@secondlayer/shared/schemas/webhooks\";\nimport { type Kysely, sql } from \"kysely\";\nimport { buildForFormat } from \"./formats/index.ts\";\nimport { recordDelivery } from \"./meter-socket.ts\";\nimport { refreshMatcher } from \"./webhook-state.ts\";\n\n/**\n * Webhook emitter — drains `webhook_outbox` and POSTs deliveries.\n *\n * Hot path: LISTEN on `webhooks:new_outbox` and `webhooks:changed`.\n * On notify, claim a batch with `FOR UPDATE SKIP LOCKED LIMIT 50`, dispatch\n * each row via HTTP, write a `webhook_deliveries` attempt row, then\n * either mark `status='delivered'` or schedule the next attempt.\n *\n * Backoff schedule (attempt → wait):\n * 0 → 30s, 1 → 2m, 2 → 10m, 3 → 1h, 4 → 6h, 5 → 24h, 6 → 72h.\n * After `max_retries` (default 7) attempts → `status='dead'`.\n *\n * Per-sub circuit breaker: 20 consecutive failures → sub flipped to\n * `paused` with `circuit_opened_at=NOW()`. Manual /resume drains backlog.\n *\n * Per-sub concurrency cap: in-memory semaphore, default 4 in-flight HTTP\n * requests per webhook. Sprint-4 adds SSRF allowlist.\n */\n\nconst BATCH_SIZE = 50;\nconst LIVE_SHARE = 0.9; // 90% of batch to non-replay, 10% to replay\nconst BACKOFF_SECONDS = [30, 120, 600, 3600, 21600, 86400, 259200];\nconst CIRCUIT_THRESHOLD = 20;\n/**\n * When a batch is claimed the outbox row's `next_attempt_at` is pushed\n * `LOCK_WINDOW_MS` into the future. Any crash between claim + settle\n * leaves the row re-claimable after this window expires — the SSOT for\n * double-dispatch prevention.\n *\n * Must exceed the maximum possible in-flight delivery time so a slow-but-alive\n * receiver's row is never re-claimed mid-delivery (duplicate dispatch). Derived\n * from the configurable timeout ceiling (`WEBHOOK_TIMEOUT_MS_CEILING`, default\n * 300_000ms — see `webhookTimeoutMsCeiling` in shared/schemas/webhooks.ts) so a\n * raised ceiling can never create a delivery window shorter than a webhook's\n * own timeout.\n */\nexport const MAX_WEBHOOK_TIMEOUT_MS: number = webhookTimeoutMsCeiling();\nexport const LOCK_WINDOW_MS: number = MAX_WEBHOOK_TIMEOUT_MS + 60_000; // ceiling + settle margin\n\n/** A sub's persistent delivery queue + in-flight counter. Lives for as long\n * as the sub has (or recently had) work — see `enqueueForSub`/`pumpSub`.\n * `sub` is refreshed on every enqueue so a status/concurrency change (a\n * pause, a resume, a config edit) is picked up by the next batch of rows\n * without waiting for anything to expire. */\ninterface SubQueue {\n\tsub: Webhook;\n\tqueue: WebhookOutbox[];\n\tinFlight: number;\n}\n\ninterface RunningState {\n\trunning: boolean;\n\t/** Per-webhook queue + concurrency counter — see `SubQueue`. Replaces a\n\t * bare in-flight counter so dispatch can be decoupled from the claim\n\t * transaction (see `claimAndDispatchOnce`): claiming a batch no longer\n\t * waits for a sub's HTTP calls to finish before the NEXT claim can run,\n\t * it just hands rows to that sub's queue and returns. */\n\tsubQueues: Map<string, SubQueue>;\n\tclaimInFlight: boolean;\n\t/** Set when a wake (NOTIFY) arrives while a claim cycle is already\n\t * running. The in-flight cycle checks this before releasing the lock —\n\t * see `claimAndDrain`'s \"drain-loop\" pass — so rows that land mid-drain\n\t * aren't stranded until an unrelated future wake or the safety poll. */\n\tclaimPending: boolean;\n\t/** The promise of whichever `claimAndDrain` call currently owns\n\t * `claimInFlight` (assigned by that call itself, before its first\n\t * `await`, so there is no race with a concurrent caller that just sets\n\t * `claimPending` and returns). `stopEmitter`'s clean-stop path awaits\n\t * this so shutdown doesn't race a claim pass that's mid-dispatch. */\n\tcurrentClaim: Promise<number> | null;\n\t/** Every `runOne` promise currently dispatching, added by `pumpSub` at\n\t * start and removed on settle. `stopEmitter` awaits this set (bounded)\n\t * so it never reports \"stopped\" while a delivery is still mid-flight. */\n\tinFlightRuns: Set<Promise<void>>;\n}\n\nfunction nextDelaySeconds(attempt: number): number {\n\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\treturn BACKOFF_SECONDS[Math.min(attempt, BACKOFF_SECONDS.length - 1)]!;\n}\n\n// ── SSRF guard ────────────────────────────────────────────────────────\n// Block deliveries to private/loopback/link-local ranges unless\n// SECONDLAYER_ALLOW_PRIVATE_EGRESS=true (self-host + local-dev opt-in).\n\nconst PRIVATE_V4_PATTERNS = [\n\t/^127\\./, // loopback\n\t/^10\\./, // private class A\n\t/^172\\.(1[6-9]|2\\d|3[01])\\./, // private class B\n\t/^192\\.168\\./, // private class C\n\t/^169\\.254\\./, // link-local\n\t/^0\\./, // \"this\" network\n\t/^100\\.(6[4-9]|[7-9]\\d|1[01]\\d|12[0-7])\\./, // CGNAT 100.64/10\n\t/^198\\.(1[89])\\./, // benchmarking 198.18.0.0/15\n\t/^(22[4-9]|23[0-9])\\./, // multicast 224.0.0.0/4\n\t/^(24[0-9]|25[0-5])\\./, // reserved 240.0.0.0/4 (incl. 255.255.255.255 broadcast)\n];\n\n/**\n * Classify a raw IP address literal (v4 or v6, no brackets, already lowercased\n * or not) as private/loopback/link-local/reserved. Covers v4 private ranges +\n * link-local (incl. `169.254.169.254`), `0.0.0.0`, benchmarking\n * (`198.18.0.0/15`), multicast (`224.0.0.0/4`), reserved/broadcast\n * (`240.0.0.0/4`, incl. `255.255.255.255`), IPv6 loopback (`::1`), unspecified\n * (`::`), unique-local (`fc00::/7`), link-local (`fe80::/10`), IPv6 multicast\n * (`ff00::/8`), IPv4-mapped IPv6 (`::ffff:127.0.0.1`, `::ffff:7f00:0001`), and\n * NAT64 (`64:ff9b::/96`, embedded v4 classified the same as IPv4-mapped).\n *\n * Shared by `isPrivateEgress` (literal-hostname fast-fail) and the resolved-\n * DNS-address check in `checkEgressAllowed` (rebinding mitigation).\n */\nfunction isPrivateIp(address: string): boolean {\n\tconst host = address.toLowerCase();\n\n\tif (host === \"0.0.0.0\") return true;\n\tif (host === \"::\" || host === \"::1\") return true;\n\t// Unique-local (fc00::/7) + link-local (fe80::/10) + multicast (ff00::/8)\n\tif (/^f[cd][0-9a-f]{2}:/.test(host)) return true;\n\tif (/^fe[89ab][0-9a-f]:/.test(host)) return true;\n\tif (/^ff[0-9a-f]{2}:/.test(host)) return true;\n\n\t// IPv4-mapped IPv6 — `::ffff:127.0.0.1` or `::ffff:7f00:0001`\n\tconst mapped = host.match(/^::ffff:(.+)$/);\n\tif (mapped) {\n\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\tconst inner = mapped[1]!;\n\t\t// Dotted form: rerun v4 checks.\n\t\tif (/^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(inner)) {\n\t\t\tfor (const p of PRIVATE_V4_PATTERNS) if (p.test(inner)) return true;\n\t\t}\n\t\t// Hex form: 7f00:0001 → 127.0.0.1\n\t\tconst hex = inner.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);\n\t\tif (hex) {\n\t\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\t\tconst a = Number.parseInt(hex[1]!, 16);\n\t\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\t\tconst b = Number.parseInt(hex[2]!, 16);\n\t\t\tconst dotted = `${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`;\n\t\t\tfor (const p of PRIVATE_V4_PATTERNS) if (p.test(dotted)) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t// NAT64 (`64:ff9b::/96`) embeds an IPv4 address in the low 32 bits —\n\t// classify it exactly like the IPv4-mapped case above (dotted or hex).\n\tconst nat64 = host.match(/^64:ff9b::(.+)$/);\n\tif (nat64) {\n\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\tconst inner = nat64[1]!;\n\t\tif (/^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(inner)) {\n\t\t\tfor (const p of PRIVATE_V4_PATTERNS) if (p.test(inner)) return true;\n\t\t}\n\t\tconst hex = inner.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);\n\t\tif (hex) {\n\t\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\t\tconst a = Number.parseInt(hex[1]!, 16);\n\t\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\t\tconst b = Number.parseInt(hex[2]!, 16);\n\t\t\tconst dotted = `${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`;\n\t\t\tfor (const p of PRIVATE_V4_PATTERNS) if (p.test(dotted)) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tfor (const p of PRIVATE_V4_PATTERNS) {\n\t\tif (p.test(host)) return true;\n\t}\n\treturn false;\n}\n\n/**\n * Reject hostnames spelled as private IPs (cheap, synchronous, no I/O — a\n * fast-fail pre-filter). DNS-level rebinding (hostname that *resolves* to a\n * private IP at egress time) is NOT caught here — see `checkEgressAllowed`,\n * which resolves DNS and validates every returned address.\n */\nfunction isPrivateEgress(url: string): boolean {\n\tlet parsed: URL;\n\ttry {\n\t\tparsed = new URL(url);\n\t} catch {\n\t\treturn true; // malformed URL: reject\n\t}\n\tif (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n\t\treturn true;\n\t}\n\n\t// Strip brackets from IPv6 literals.\n\tconst raw = parsed.hostname.toLowerCase();\n\tconst host =\n\t\traw.startsWith(\"[\") && raw.endsWith(\"]\") ? raw.slice(1, -1) : raw;\n\n\tif (host === \"localhost\") return true;\n\treturn isPrivateIp(host);\n}\n\nfunction allowPrivateEgress(): boolean {\n\treturn process.env.SECONDLAYER_ALLOW_PRIVATE_EGRESS === \"true\";\n}\n\ninterface ResolvedAddress {\n\taddress: string;\n\tfamily: number;\n}\n\ntype DnsLookupFn = (hostname: string) => Promise<ResolvedAddress[]>;\n\nasync function defaultDnsLookup(hostname: string): Promise<ResolvedAddress[]> {\n\treturn dnsLookup(hostname, { all: true });\n}\n\nlet dnsLookupImpl: DnsLookupFn = defaultDnsLookup;\n\n/**\n * Test-only seam: inject a fake DNS resolver so tests can simulate a hostname\n * resolving to a private/metadata IP without depending on real DNS or\n * network access. Pass `null` to restore the real `dns.lookup`-backed\n * resolver. Production code never calls this — only `ssrf.test.ts` does.\n */\nexport function __setDnsLookupForTest(fn: DnsLookupFn | null): void {\n\tdnsLookupImpl = fn ?? defaultDnsLookup;\n}\n\nconst ALLOW_HINT = \"(set SECONDLAYER_ALLOW_PRIVATE_EGRESS=true to allow)\";\n\n/**\n * Full egress check: literal fast-fail, then resolve DNS and reject if *any*\n * returned address (not just the first) is private/loopback/link-local —\n * a single private answer among several is enough for an attacker to abuse.\n *\n * Residual TOCTOU: this resolves-and-validates *immediately before* `fetch()`\n * but does not pin the actual connection to the validated address. True\n * pinning (resolve once, connect to that literal IP, keep the original `Host`\n * header/SNI) was attempted per plan f053 via a custom undici `Agent` with\n * `connect.lookup` passed as `fetch()`'s `dispatcher` — the standard Node\n * ecosystem pattern for this. Empirically, under Bun 1.3.10, that hook was\n * silently never invoked (confirmed with a throwaway smoke test hitting a\n * real local HTTP server: `lookupCalled` stayed `false` and the connection\n * failed even though the dispatcher was passed) — Bun's `fetch()` does not\n * implement undici's `connect.lookup` dispatcher hook, whether reached via\n * the global `fetch` or `import { fetch } from \"undici\"` (Bun overrides\n * \"undici\"'s `fetch` export with its own native implementation regardless of\n * an installed real `undici` package). Re-tested under Bun 1.4.2 (plan 043):\n * identical result — `lookupCalled` still stays `false` via both the global\n * `fetch` and `import { fetch } from \"undici\"`, so the gap remains unfixed at\n * this version too. So a second DNS answer between this check and `fetch()`'s\n * own resolution (true rebinding, not just a one-time private answer) could\n * in theory still slip through. This closes the practical rebinding gap\n * (attacker's hostname resolves private at delivery time) without adding a\n * dependency; a network-level egress allowlist/proxy is the recommended\n * defense-in-depth for the remaining TOCTOU sliver.\n */\nexport async function checkEgressAllowed(url: string): Promise<string | null> {\n\tif (isPrivateEgress(url)) {\n\t\treturn `refused private egress: literal address ${ALLOW_HINT}`;\n\t}\n\n\tlet parsed: URL;\n\ttry {\n\t\tparsed = new URL(url);\n\t} catch {\n\t\treturn `refused private egress: malformed URL ${ALLOW_HINT}`;\n\t}\n\tif (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n\t\treturn `refused private egress: unsupported protocol ${ALLOW_HINT}`;\n\t}\n\n\tconst raw = parsed.hostname.toLowerCase();\n\tconst hostname =\n\t\traw.startsWith(\"[\") && raw.endsWith(\"]\") ? raw.slice(1, -1) : raw;\n\n\tlet resolved: ResolvedAddress[];\n\ttry {\n\t\tresolved = await dnsLookupImpl(hostname);\n\t} catch (err) {\n\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\treturn `refused private egress: DNS resolution failed (${msg}) ${ALLOW_HINT}`;\n\t}\n\tif (resolved.length === 0) {\n\t\treturn `refused private egress: DNS resolution returned no addresses ${ALLOW_HINT}`;\n\t}\n\tfor (const { address } of resolved) {\n\t\tif (isPrivateIp(address)) {\n\t\t\treturn `refused private egress: hostname resolves to private address ${address} ${ALLOW_HINT}`;\n\t\t}\n\t}\n\treturn null;\n}\n\n/** The wire result of one POST attempt (no DB side effect) — shared by the\n * emitter hot path and the `deliverTestEvent` test path. */\ninterface PostResult {\n\tok: boolean;\n\tstatusCode: number | null;\n\terror: string | null;\n\tdurationMs: number;\n\tresponseBody: string | null;\n\tresponseHeaders: Record<string, string> | null;\n}\n\n/**\n * Bound on redirect hops honored while delivering a webhook. `fetch` is\n * called with `redirect: \"manual\"` so a 3xx never auto-follows — each hop's\n * target is re-validated through `checkEgressAllowed` before it is fetched\n * (see `postToWebhook`), otherwise a `Location` header pointing at a\n * private/metadata address would bypass the egress guard entirely. Counts\n * every fetch issued (the initial request plus each redirect), so this is\n * also the max number of `fetch` calls one delivery attempt can make.\n */\nexport const MAX_REDIRECT_HOPS: number = 3;\n\n/** POST a pre-built body to a webhook URL with the SSRF guard + timeout.\n * Pure transport: returns the attempt result; the caller logs the delivery row.\n * Follows redirects manually (up to `MAX_REDIRECT_HOPS`), re-running the\n * egress guard against every hop's target — the guard only protects the\n * first request otherwise, and a webhook target can redirect to a private/\n * metadata address to read back the response after f053 pinned the guard to\n * just the original URL. */\nasync function postToWebhook(\n\turl: string,\n\tbody: string,\n\theaders: Record<string, string>,\n\ttimeoutMs: number,\n): Promise<PostResult> {\n\tconst start = performance.now();\n\tlet target = url;\n\tlet statusCode: number | null = null;\n\tlet error: string | null = null;\n\tlet ok = false;\n\tlet responseBody = \"\";\n\tlet responseHeaders: Record<string, string> | null = null;\n\ttry {\n\t\tfor (let hop = 0; ; hop++) {\n\t\t\tif (!allowPrivateEgress()) {\n\t\t\t\tconst refusal = await checkEgressAllowed(target);\n\t\t\t\tif (refusal) {\n\t\t\t\t\tlogger.warn(\"[emitter] refused private egress\", {\n\t\t\t\t\t\turl: target,\n\t\t\t\t\t\treason: refusal,\n\t\t\t\t\t});\n\t\t\t\t\treturn {\n\t\t\t\t\t\tok: false,\n\t\t\t\t\t\tstatusCode: null,\n\t\t\t\t\t\terror: refusal,\n\t\t\t\t\t\tdurationMs: Math.round(performance.now() - start),\n\t\t\t\t\t\tresponseBody: null,\n\t\t\t\t\t\tresponseHeaders: null,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (hop >= MAX_REDIRECT_HOPS) {\n\t\t\t\treturn {\n\t\t\t\t\tok: false,\n\t\t\t\t\tstatusCode,\n\t\t\t\t\terror: `too many redirects (exceeded ${MAX_REDIRECT_HOPS} hops)`,\n\t\t\t\t\tdurationMs: Math.round(performance.now() - start),\n\t\t\t\t\tresponseBody: null,\n\t\t\t\t\tresponseHeaders: null,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst res = await fetch(target, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders,\n\t\t\t\tbody,\n\t\t\t\tredirect: \"manual\",\n\t\t\t\tsignal: AbortSignal.timeout(timeoutMs),\n\t\t\t});\n\t\t\tstatusCode = res.status;\n\n\t\t\tconst location = res.headers.get(\"location\");\n\t\t\tif (res.status >= 300 && res.status < 400 && location) {\n\t\t\t\ttarget = new URL(location, target).toString();\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tok = res.ok;\n\t\t\t// Collect small response preview for the delivery log (≤8KB).\n\t\t\tconst buf = await res.arrayBuffer();\n\t\t\tconst truncated = buf.byteLength > 8192 ? buf.slice(0, 8192) : buf;\n\t\t\tresponseBody = Buffer.from(truncated).toString(\"utf8\");\n\t\t\tresponseHeaders = Object.fromEntries(res.headers.entries());\n\t\t\tbreak;\n\t\t}\n\t} catch (err) {\n\t\terror = err instanceof Error ? err.message : String(err);\n\t}\n\treturn {\n\t\tok,\n\t\tstatusCode,\n\t\terror,\n\t\tdurationMs: Math.round(performance.now() - start),\n\t\tresponseBody: responseBody || null,\n\t\tresponseHeaders,\n\t};\n}\n\n/**\n * Test-only seam: exposes `postToWebhook` (otherwise module-private) so\n * redirect/egress behavior can be exercised directly against a stubbed\n * `fetch` + injected DNS lookup, without standing up the DB-backed emitter\n * loop. Production code never calls this — only tests do.\n */\nexport const __postToWebhookForTest: (\n\turl: string,\n\tbody: string,\n\theaders: Record<string, string>,\n\ttimeoutMs: number,\n) => Promise<PostResult> = postToWebhook;\n\nasync function dispatchOne(\n\tdb: Kysely<Database>,\n\toutboxRow: WebhookOutbox,\n\tsub: Webhook,\n): Promise<{\n\tok: boolean;\n\tstatusCode: number | null;\n\terror: string | null;\n\tdurationMs: number;\n}> {\n\tconst { body, headers } = buildForFormat(\n\t\toutboxRow,\n\t\tsub,\n\t\tgetWebhookSigningSecret(sub),\n\t);\n\tconst r = await postToWebhook(sub.url, body, headers, sub.timeout_ms);\n\n\tconst attempt = outboxRow.attempt + 1;\n\tawait db\n\t\t.insertInto(\"webhook_deliveries\")\n\t\t.values({\n\t\t\toutbox_id: outboxRow.id,\n\t\t\twebhook_id: outboxRow.webhook_id,\n\t\t\tattempt,\n\t\t\tstatus_code: r.statusCode,\n\t\t\tresponse_headers: r.responseHeaders,\n\t\t\tresponse_body: r.responseBody,\n\t\t\terror_message: r.error,\n\t\t\tduration_ms: r.durationMs,\n\t\t})\n\t\t.execute();\n\n\treturn {\n\t\tok: r.ok,\n\t\tstatusCode: r.statusCode,\n\t\terror: r.error,\n\t\tdurationMs: r.durationMs,\n\t};\n}\n\n/** A representative (non-persisted) outbox row for a test delivery, shaped to the\n * webhook's kind so `buildForFormat` produces a realistic body. */\nfunction buildTestOutboxRow(sub: Webhook): WebhookOutbox {\n\tconst now = new Date();\n\treturn {\n\t\tid: randomUUID(),\n\t\twebhook_id: sub.id,\n\t\tkind: sub.kind,\n\t\tsubgraph_name: sub.subgraph_name ?? null,\n\t\ttable_name: sub.table_name ?? null,\n\t\tblock_height: 0,\n\t\ttx_id: null,\n\t\trow_pk: null,\n\t\tevent_type:\n\t\t\tsub.kind === \"chain\"\n\t\t\t\t? \"chain.test.apply\"\n\t\t\t\t: `${sub.subgraph_name ?? \"subgraph\"}.${sub.table_name ?? \"test\"}.created`,\n\t\tpayload: {\n\t\t\ttest: true,\n\t\t\tmessage: \"Secondlayer test delivery\",\n\t\t\twebhook_id: sub.id,\n\t\t\tsent_at: now.toISOString(),\n\t\t},\n\t\tdedup_key: `test:${sub.id}:${now.getTime()}`,\n\t\tattempt: 0,\n\t\tnext_attempt_at: now,\n\t\tstatus: \"pending\",\n\t\tis_replay: false,\n\t\tblock_time: null,\n\t\tdelivered_at: null,\n\t\tfailed_at: null,\n\t\tlocked_by: null,\n\t\tlocked_until: null,\n\t\tlast_error: null,\n\t\tcreated_at: now,\n\t};\n}\n\n/**\n * Build a representative webhook for `sub`'s configured format, POST it (same\n * SSRF guard + timeout + signing as a real delivery), and log a delivery row\n * with a null `outbox_id` so it appears under the webhook's deliveries\n * without being tied to a queued event. Powers `POST /:id/test`.\n */\nexport async function deliverTestEvent(\n\tdb: Kysely<Database>,\n\tsub: Webhook,\n): Promise<WebhookTestResult> {\n\tconst testRow = buildTestOutboxRow(sub);\n\tconst { body, headers } = buildForFormat(\n\t\ttestRow,\n\t\tsub,\n\t\tgetWebhookSigningSecret(sub),\n\t);\n\tconst r = await postToWebhook(sub.url, body, headers, sub.timeout_ms);\n\tconst inserted = await db\n\t\t.insertInto(\"webhook_deliveries\")\n\t\t.values({\n\t\t\toutbox_id: null,\n\t\t\twebhook_id: sub.id,\n\t\t\tattempt: 1,\n\t\t\tstatus_code: r.statusCode,\n\t\t\tresponse_headers: r.responseHeaders,\n\t\t\tresponse_body: r.responseBody,\n\t\t\terror_message: r.error,\n\t\t\tduration_ms: r.durationMs,\n\t\t})\n\t\t.returning(\"id\")\n\t\t.executeTakeFirstOrThrow();\n\treturn {\n\t\tok: r.ok,\n\t\tstatusCode: r.statusCode,\n\t\terror: r.error,\n\t\tdurationMs: r.durationMs,\n\t\tdeliveryId: inserted.id,\n\t};\n}\n\nasync function settleDelivered(\n\tdb: Kysely<Database>,\n\toutboxRow: WebhookOutbox,\n): Promise<void> {\n\tawait db.transaction().execute(async (tx) => {\n\t\tconst result = await tx\n\t\t\t.updateTable(\"webhook_outbox\")\n\t\t\t.set({\n\t\t\t\tstatus: \"delivered\",\n\t\t\t\tdelivered_at: new Date(),\n\t\t\t\tattempt: outboxRow.attempt + 1,\n\t\t\t\tlocked_by: null,\n\t\t\t\tlocked_until: null,\n\t\t\t})\n\t\t\t// Guard against a reorg that marked this row `dead` (orphaned) while\n\t\t\t// the POST was in flight — settling a delivery outcome must never\n\t\t\t// resurrect a row the reorg already decided is gone. `status='pending'`\n\t\t\t// is the only state a claimed row can settle from.\n\t\t\t.where(\"id\", \"=\", outboxRow.id)\n\t\t\t.where(\"status\", \"=\", \"pending\")\n\t\t\t.executeTakeFirst();\n\t\tif (Number(result.numUpdatedRows ?? 0) === 0) {\n\t\t\tlogger.info(\"webhook.settle.orphaned\", {\n\t\t\t\toutboxId: outboxRow.id,\n\t\t\t\twebhookId: outboxRow.webhook_id,\n\t\t\t\toutcome: \"delivered\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\t\tawait tx\n\t\t\t.updateTable(\"webhooks\")\n\t\t\t.set({\n\t\t\t\tlast_delivery_at: new Date(),\n\t\t\t\tlast_success_at: new Date(),\n\t\t\t\tcircuit_failures: 0,\n\t\t\t\tlast_error: null,\n\t\t\t\tupdated_at: new Date(),\n\t\t\t})\n\t\t\t.where(\"id\", \"=\", outboxRow.webhook_id)\n\t\t\t.execute();\n\n\t\t// Hosted-stack event meter (plan 044, step 5): counts a real delivery,\n\t\t// never a retry (this function only runs on the successful outcome) and\n\t\t// never a test delivery (`deliverTestEvent` never calls this). A no-op\n\t\t// on self-host (WEBHOOK_METER_SOCKET unset).\n\t\trecordDelivery();\n\t});\n}\n\nasync function settleFailed(\n\tdb: Kysely<Database>,\n\toutboxRow: WebhookOutbox,\n\tsub: Webhook,\n\terrText: string,\n): Promise<void> {\n\tconst attempt = outboxRow.attempt + 1;\n\tconst isDead = attempt >= sub.max_retries;\n\tconst nextAt = isDead\n\t\t? null\n\t\t: new Date(Date.now() + nextDelaySeconds(outboxRow.attempt) * 1000);\n\n\tawait db.transaction().execute(async (tx) => {\n\t\tconst result = await tx\n\t\t\t.updateTable(\"webhook_outbox\")\n\t\t\t.set({\n\t\t\t\tattempt,\n\t\t\t\tnext_attempt_at: nextAt ?? new Date(),\n\t\t\t\tstatus: isDead ? \"dead\" : \"pending\",\n\t\t\t\tfailed_at: isDead ? new Date() : null,\n\t\t\t\tlocked_by: null,\n\t\t\t\tlocked_until: null,\n\t\t\t})\n\t\t\t// Guard against a reorg that marked this row `dead` (orphaned) while\n\t\t\t// the POST was in flight — see the matching guard in `settleDelivered`.\n\t\t\t.where(\"id\", \"=\", outboxRow.id)\n\t\t\t.where(\"status\", \"=\", \"pending\")\n\t\t\t.executeTakeFirst();\n\t\tif (Number(result.numUpdatedRows ?? 0) === 0) {\n\t\t\tlogger.info(\"webhook.settle.orphaned\", {\n\t\t\t\toutboxId: outboxRow.id,\n\t\t\t\twebhookId: outboxRow.webhook_id,\n\t\t\t\toutcome: \"failed\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\t// Atomic increment — concurrent failures must not clobber each other.\n\t\t// `RETURNING circuit_failures` gives us the post-increment value to\n\t\t// decide whether this failure tripped the circuit.\n\t\tconst incResult = await sql<{ circuit_failures: number }>`\n\t\t\tUPDATE webhooks\n\t\t\tSET circuit_failures = circuit_failures + 1,\n\t\t\t\tlast_delivery_at = NOW(),\n\t\t\t\tlast_error = ${errText.slice(0, 500)},\n\t\t\t\tupdated_at = NOW()\n\t\t\tWHERE id = ${sub.id}\n\t\t\tRETURNING circuit_failures\n\t\t`.execute(tx);\n\t\tconst newFailures =\n\t\t\tincResult.rows[0]?.circuit_failures ?? sub.circuit_failures + 1;\n\t\tconst shouldTripCircuit = newFailures >= CIRCUIT_THRESHOLD;\n\n\t\tif (shouldTripCircuit) {\n\t\t\t// Transition to paused only on the first failure that crossed\n\t\t\t// the threshold — additional failures in-flight harmlessly\n\t\t\t// re-set the same fields.\n\t\t\tawait tx\n\t\t\t\t.updateTable(\"webhooks\")\n\t\t\t\t.set({\n\t\t\t\t\tstatus: \"paused\",\n\t\t\t\t\tcircuit_opened_at: new Date(),\n\t\t\t\t\tupdated_at: new Date(),\n\t\t\t\t})\n\t\t\t\t.where(\"id\", \"=\", sub.id)\n\t\t\t\t.execute();\n\t\t\tlogger.warn(\n\t\t\t\t\"Webhook circuit tripped — paused after consecutive failures\",\n\t\t\t\t{\n\t\t\t\t\twebhook: sub.name,\n\t\t\t\t\tfailures: newFailures,\n\t\t\t\t},\n\t\t\t);\n\t\t}\n\t});\n}\n\n/** What woke this claim cycle — purely for the diagnostic log below; the\n * claim/dispatch logic itself doesn't branch on it. `\"drain-loop\"` marks a\n * pass this function re-ran on itself (see `claimAndDrain`) because a wake\n * arrived (or a per-lane batch cap was hit) while the previous pass was\n * still claiming. `\"changed\"` is a resume (or any webhook edit) — see the\n * `webhooks:changed` listener. */\ntype ClaimTrigger = \"notify\" | \"poll\" | \"startup\" | \"changed\" | \"drain-loop\";\n\n/**\n * The regression this closes: a NOTIFY that arrives while a claim cycle is\n * ALREADY running used to be dropped on the floor (the `claimInFlight` guard\n * below just returned 0), and nothing re-checked once that cycle finished.\n * Rows inserted mid-cycle — a normal burst of ~6 chain-webhook matches per\n * block — sat `pending` until an UNRELATED future wake happened to fire, or\n * the 2-minute safety poll.\n *\n * The holder of `claimInFlight` now keeps looping (`claimPending`, set by any\n * caller that finds the lock held) until a pass claims nothing NEW and no one\n * asked for a recheck while it ran — so a burst drains fully off one wake\n * instead of needing one wake per claim-sized chunk. Critically, this lock\n * wraps ONLY `claimAndDispatchOnce`'s DB claim query, never dispatch (see its\n * doc) — a slow or hanging receiver must never hold up the next claim.\n */\nasync function claimAndDrain(\n\tdb: Kysely<Database>,\n\tstate: RunningState,\n\temitterId: string,\n\ttrigger: ClaimTrigger,\n): Promise<number> {\n\tif (state.claimInFlight) {\n\t\tstate.claimPending = true;\n\t\treturn 0;\n\t}\n\tstate.claimInFlight = true;\n\t// Assigned synchronously (before any `await` in `runClaimLoop` yields back\n\t// to the event loop) so `stopEmitter` can always await the pass that\n\t// actually owns the lock, never a concurrent caller's already-resolved\n\t// `claimPending` short-circuit above.\n\tconst drainPromise = runClaimLoop(db, state, emitterId, trigger);\n\tstate.currentClaim = drainPromise;\n\ttry {\n\t\treturn await drainPromise;\n\t} finally {\n\t\tif (state.currentClaim === drainPromise) state.currentClaim = null;\n\t}\n}\n\nasync function runClaimLoop(\n\tdb: Kysely<Database>,\n\tstate: RunningState,\n\temitterId: string,\n\ttrigger: ClaimTrigger,\n): Promise<number> {\n\tlet totalClaimed = 0;\n\ttry {\n\t\tlet currentTrigger = trigger;\n\t\tfor (;;) {\n\t\t\t// Stop requested: claim no new rows. Anything already claimed by an\n\t\t\t// in-flight `claimAndDispatchOnce` call still gets handed to its\n\t\t\t// sub's queue, but `pumpSub`'s own `state.running` check means it\n\t\t\t// won't dispatch — those rows just keep their lock until the next\n\t\t\t// emitter re-claims them after LOCK_WINDOW_MS.\n\t\t\tif (!state.running) break;\n\t\t\tstate.claimPending = false;\n\t\t\tconst result = await claimAndDispatchOnce(\n\t\t\t\tdb,\n\t\t\t\tstate,\n\t\t\t\temitterId,\n\t\t\t\tcurrentTrigger,\n\t\t\t);\n\t\t\ttotalClaimed += result.claimed;\n\t\t\t// Keep going while either: a wake landed mid-pass (claimPending —\n\t\t\t// set by the guard above, from another caller), or either lane\n\t\t\t// (live/replay) came back full, meaning more may be waiting behind\n\t\t\t// it. Comparing against the LANE limits, not the combined batch\n\t\t\t// size, matters: live traffic alone is capped at `liveLimit` (90%\n\t\t\t// of BATCH_SIZE) and would never reach the full BATCH_SIZE on its\n\t\t\t// own, so that comparison never re-looped on a live-only backlog.\n\t\t\tif (!state.claimPending && !result.mayHaveMore) break;\n\t\t\tcurrentTrigger = \"drain-loop\";\n\t\t}\n\t} finally {\n\t\tstate.claimInFlight = false;\n\t}\n\treturn totalClaimed;\n}\n\ninterface ClaimResult {\n\tclaimed: number;\n\tdispatched: number;\n\treleased: number;\n\tmayHaveMore: boolean;\n}\n\n/**\n * One claim pass: SELECT a batch, mark it locked, then hand each sub's slice\n * to its persistent queue and return — WITHOUT waiting for any of it to\n * dispatch. Caller (`claimAndDrain`) owns the re-loop and the `claimInFlight`\n * lock, which now covers only this function's DB work.\n *\n * This decoupling is the fix for the emitter-wide latency regression: the\n * previous version awaited `Promise.all(subIds.map(drainForSub))` — every\n * sub's FULL dispatch — before returning, so the slowest receiver in a batch\n * (a hung connection, a multi-second response) held the process-wide claim\n * lock for as long as IT took, starving every other sub's claims (including\n * a fast sub's own fresh rows) for that whole time. Claiming is now a single\n * short transaction; delivery happens on each sub's own independent\n * queue/semaphore (`enqueueForSub`/`pumpSub`), so one sub's pace never\n * gates another's.\n *\n * Also fixes the \"stuck rows\" bug: a row claimed for a sub that turns out to\n * be `paused` (status read fresh here, after the claim) is released\n * IMMEDIATELY — `next_attempt_at` reset to now, lock cleared — instead of\n * being silently abandoned with its lock held for the full `LOCK_WINDOW_MS`.\n * The old code's `if (sub.status !== \"active\") return;` left the row\n * claimed-but-untouched: locked, `next_attempt_at` pushed far into the\n * future, never dispatched, never settled — a resumed webhook's backlog\n * then only drained once each row's stale lock happened to expire.\n */\nasync function claimAndDispatchOnce(\n\tdb: Kysely<Database>,\n\tstate: RunningState,\n\temitterId: string,\n\ttrigger: ClaimTrigger,\n): Promise<ClaimResult> {\n\t// FOR UPDATE SKIP LOCKED — multiple emitters split the batch.\n\t// 90/10 live vs replay so a big replay doesn't starve live emits.\n\tconst liveLimit = Math.max(1, Math.round(BATCH_SIZE * LIVE_SHARE));\n\tconst replayLimit = BATCH_SIZE - liveLimit;\n\tconst { combined, liveCount, replayCount } = await db\n\t\t.transaction()\n\t\t.execute(async (tx) => {\n\t\t\tconst live = await sql<WebhookOutbox>`\n\t\t\t\t\tSELECT * FROM webhook_outbox\n\t\t\t\t\tWHERE status = 'pending'\n\t\t\t\t\t\tAND next_attempt_at <= NOW()\n\t\t\t\t\t\tAND is_replay = FALSE\n\t\t\t\t\tORDER BY next_attempt_at ASC\n\t\t\t\t\tFOR UPDATE SKIP LOCKED\n\t\t\t\t\tLIMIT ${sql.lit(liveLimit)}\n\t\t\t\t`.execute(tx);\n\t\t\tconst replay = await sql<WebhookOutbox>`\n\t\t\t\t\tSELECT * FROM webhook_outbox\n\t\t\t\t\tWHERE status = 'pending'\n\t\t\t\t\t\tAND next_attempt_at <= NOW()\n\t\t\t\t\t\tAND is_replay = TRUE\n\t\t\t\t\tORDER BY next_attempt_at ASC\n\t\t\t\t\tFOR UPDATE SKIP LOCKED\n\t\t\t\t\tLIMIT ${sql.lit(replayLimit)}\n\t\t\t\t`.execute(tx);\n\n\t\t\tconst rows = [...live.rows, ...replay.rows];\n\t\t\tif (rows.length === 0) {\n\t\t\t\treturn { combined: rows, liveCount: 0, replayCount: 0 };\n\t\t\t}\n\n\t\t\t// Push `next_attempt_at` forward by the lock window. This is\n\t\t\t// the only defense against double-dispatch if the emitter\n\t\t\t// process crashes mid-HTTP-call: the row won't be re-claimable\n\t\t\t// until `LOCK_WINDOW_MS` elapses, giving us a stale-lock\n\t\t\t// recovery window. `settleDelivered`/`settleFailed` overrides\n\t\t\t// this on the success/failure path (and the paused-sub release\n\t\t\t// below overrides it on the \"never got dispatched\" path).\n\t\t\tconst now = new Date();\n\t\t\tconst lockUntil = new Date(now.getTime() + LOCK_WINDOW_MS);\n\t\t\tawait tx\n\t\t\t\t.updateTable(\"webhook_outbox\")\n\t\t\t\t.set({\n\t\t\t\t\tlocked_by: emitterId,\n\t\t\t\t\tlocked_until: lockUntil,\n\t\t\t\t\tnext_attempt_at: lockUntil,\n\t\t\t\t})\n\t\t\t\t.where(\n\t\t\t\t\t\"id\",\n\t\t\t\t\t\"in\",\n\t\t\t\t\trows.map((r) => r.id),\n\t\t\t\t)\n\t\t\t\t.execute();\n\t\t\treturn {\n\t\t\t\tcombined: rows,\n\t\t\t\tliveCount: live.rows.length,\n\t\t\t\treplayCount: replay.rows.length,\n\t\t\t};\n\t\t});\n\n\tif (combined.length === 0) {\n\t\treturn { claimed: 0, dispatched: 0, released: 0, mayHaveMore: false };\n\t}\n\n\tconst bySubId = new Map<string, WebhookOutbox[]>();\n\tfor (const row of combined) {\n\t\tconst arr = bySubId.get(row.webhook_id);\n\t\tif (arr) arr.push(row);\n\t\telse bySubId.set(row.webhook_id, [row]);\n\t}\n\n\tconst subIds = Array.from(bySubId.keys());\n\tconst subs = await db\n\t\t.selectFrom(\"webhooks\")\n\t\t.selectAll()\n\t\t.where(\"id\", \"in\", subIds)\n\t\t.execute();\n\tconst subById = new Map(subs.map((s) => [s.id, s]));\n\n\tlet dispatched = 0;\n\tlet released = 0;\n\tconst releasedIds: string[] = [];\n\tfor (const subId of subIds) {\n\t\tconst sub = subById.get(subId);\n\t\t// biome-ignore lint/style/noNonNullAssertion: key came from bySubId itself\n\t\tconst rows = bySubId.get(subId)!;\n\t\tif (!sub || sub.status !== \"active\") {\n\t\t\treleased += rows.length;\n\t\t\treleasedIds.push(...rows.map((r) => r.id));\n\t\t\tcontinue;\n\t\t}\n\t\tdispatched += rows.length;\n\t\tenqueueForSub(db, state, sub, rows);\n\t}\n\n\tif (releasedIds.length > 0) {\n\t\t// Claimable again immediately: a webhook that's already resumed by the\n\t\t// time we notice the release loses nothing waiting for LOCK_WINDOW_MS\n\t\t// (up to several minutes) to expire on its own.\n\t\tawait db\n\t\t\t.updateTable(\"webhook_outbox\")\n\t\t\t.set({ next_attempt_at: new Date(), locked_by: null, locked_until: null })\n\t\t\t.where(\"id\", \"in\", releasedIds)\n\t\t\t.execute();\n\t}\n\n\t// One line per claim pass: what woke it, how much it found, how it split\n\t// between dispatched-now and released-back (paused), how stale the oldest\n\t// row was, and how loaded the in-flight pool already was — everything\n\t// needed to tell \"briefly busy\" apart from \"actually stuck\" without\n\t// reproducing it live. `in_flight`/`concurrency` sum across only the subs\n\t// THIS pass touched (a quiet sub's unrelated cap doesn't dilute the\n\t// reading).\n\tconst oldestCreatedAtMs = Math.min(\n\t\t...combined.map((r) => new Date(r.created_at).getTime()),\n\t);\n\tconst inFlight = subIds.reduce(\n\t\t(sum, id) => sum + (state.subQueues.get(id)?.inFlight ?? 0),\n\t\t0,\n\t);\n\tconst concurrency = subIds.reduce(\n\t\t(sum, id) => sum + (subById.get(id)?.concurrency || 4),\n\t\t0,\n\t);\n\tlogger.info(\"emitter claim cycle\", {\n\t\tevent: \"emitter_claim\",\n\t\ttrigger,\n\t\tclaimed: combined.length,\n\t\tdispatched,\n\t\treleased,\n\t\toldest_created_at_age_ms: Date.now() - oldestCreatedAtMs,\n\t\tin_flight: inFlight,\n\t\tconcurrency,\n\t});\n\n\treturn {\n\t\tclaimed: combined.length,\n\t\tdispatched,\n\t\treleased,\n\t\tmayHaveMore: liveCount >= liveLimit || replayCount >= replayLimit,\n\t};\n}\n\n/**\n * Hand `rows` to `sub`'s persistent queue and try to start dispatching\n * immediately. Decoupled from the claim transaction on purpose (see\n * `claimAndDispatchOnce`'s doc) — a slow or hanging sub's in-flight HTTP\n * calls must never hold up the next claim, for this sub or any other.\n *\n * `sub` is refreshed here on every call (not just on first creation) so a\n * status/concurrency change since the last batch — most importantly a\n * pause — is picked up by `pumpSub` without waiting for anything to expire.\n */\nfunction enqueueForSub(\n\tdb: Kysely<Database>,\n\tstate: RunningState,\n\tsub: Webhook,\n\trows: WebhookOutbox[],\n): void {\n\tlet sq = state.subQueues.get(sub.id);\n\tif (!sq) {\n\t\tsq = { sub, queue: [], inFlight: 0 };\n\t\tstate.subQueues.set(sub.id, sq);\n\t} else {\n\t\tsq.sub = sub;\n\t}\n\tsq.queue.push(...rows);\n\tpumpSub(db, state, sub.id);\n}\n\n/**\n * Start as many workers as the sub's remaining concurrency allows, pulling\n * from its queue. Reentrant-safe without an explicit lock: every branch here\n * is synchronous (the only `await` lives in `runOne`, called but not\n * awaited), so JS's single-threaded execution means two calls — one from\n * `enqueueForSub`, one from a just-finished worker's `finally` — can never\n * interleave mid-loop; each runs to completion before the other starts.\n */\nfunction pumpSub(\n\tdb: Kysely<Database>,\n\tstate: RunningState,\n\tsubId: string,\n): void {\n\tconst sq = state.subQueues.get(subId);\n\tif (!sq) return;\n\tconst cap = sq.sub.concurrency || 4;\n\twhile (\n\t\tstate.running &&\n\t\tsq.sub.status === \"active\" &&\n\t\tsq.inFlight < cap &&\n\t\tsq.queue.length > 0\n\t) {\n\t\tconst row = sq.queue.shift();\n\t\tif (!row) break;\n\t\tsq.inFlight++;\n\t\tconst runPromise = runOne(db, state, sq, row);\n\t\tstate.inFlightRuns.add(runPromise);\n\t\tvoid runPromise.finally(() => {\n\t\t\tstate.inFlightRuns.delete(runPromise);\n\t\t});\n\t}\n}\n\n/** Dispatch one claimed row end to end (POST, settle), then release its\n * in-flight slot and try to pull more work for the same sub. */\nasync function runOne(\n\tdb: Kysely<Database>,\n\tstate: RunningState,\n\tsq: SubQueue,\n\trow: WebhookOutbox,\n): Promise<void> {\n\ttry {\n\t\tconst result = await dispatchOne(db, row, sq.sub);\n\t\tif (result.ok) {\n\t\t\tawait settleDelivered(db, row);\n\t\t} else {\n\t\t\tconst err = result.error ?? `HTTP ${result.statusCode ?? \"?\"}`;\n\t\t\tawait settleFailed(db, row, sq.sub, err);\n\t\t}\n\t} catch (err) {\n\t\tlogger.error(\"Emitter dispatch crashed\", {\n\t\t\toutboxId: row.id,\n\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t});\n\t\tawait settleFailed(\n\t\t\tdb,\n\t\t\trow,\n\t\t\tsq.sub,\n\t\t\terr instanceof Error ? err.message : String(err),\n\t\t);\n\t} finally {\n\t\tsq.inFlight--;\n\t\tpumpSub(db, state, sq.sub.id);\n\t}\n}\n\nexport interface StartEmitterOptions {\n\t/** Interval for the background poll (ms). Defaults to 2 minutes. */\n\tpollIntervalMs?: number;\n\t/** Retention sweep interval (ms). Defaults to 1 hour. */\n\tretentionIntervalMs?: number;\n\t/** Bound for the returned stop function's clean-shutdown drain — how long\n\t * it waits for the current claim pass + in-flight dispatches before\n\t * giving up and reporting them abandoned. Defaults to\n\t * `MAX_WEBHOOK_TIMEOUT_MS + 5_000` (a dispatch can't legitimately run\n\t * longer than the webhook timeout ceiling). `MAX_WEBHOOK_TIMEOUT_MS` is\n\t * fixed at module load from `WEBHOOK_TIMEOUT_MS_CEILING`, so this exists\n\t * mainly for tests that need a short bound without a 5-minute wait. */\n\tstopDrainDeadlineMs?: number;\n}\n\nasync function runRetention(db: Kysely<Database>): Promise<void> {\n\t// delivered outbox >7d, deliveries >30d, dead outbox >90d\n\tawait sql`\n\t\tDELETE FROM webhook_outbox\n\t\tWHERE status = 'delivered' AND delivered_at < NOW() - interval '7 days'\n\t`.execute(db);\n\tawait sql`\n\t\tDELETE FROM webhook_deliveries\n\t\tWHERE dispatched_at < NOW() - interval '30 days'\n\t`.execute(db);\n\tawait sql`\n\t\tDELETE FROM webhook_outbox\n\t\tWHERE status = 'dead' AND failed_at < NOW() - interval '90 days'\n\t`.execute(db);\n}\n\nexport async function startEmitter(\n\topts?: StartEmitterOptions,\n): Promise<() => Promise<void>> {\n\tconst emitterId = `emitter-${Math.random().toString(36).slice(2, 10)}`;\n\tconst db = getTargetDb();\n\tconst state: RunningState = {\n\t\trunning: true,\n\t\tsubQueues: new Map(),\n\t\tclaimInFlight: false,\n\t\tclaimPending: false,\n\t\tcurrentClaim: null,\n\t\tinFlightRuns: new Set(),\n\t};\n\tconst pollIntervalMs = opts?.pollIntervalMs ?? 120_000;\n\tconst retentionIntervalMs = opts?.retentionIntervalMs ?? 60 * 60_000;\n\n\tlogger.info(\"[emitter] started\", { id: emitterId });\n\n\t// Bootstrap matcher from active subs. Retry with backoff — if this\n\t// stays broken, fail loud rather than run with an empty matcher (which\n\t// would silently drop every block's outbox emissions until the next\n\t// webhook CRUD fired `webhooks:changed`).\n\tconst MATCHER_BOOT_ATTEMPTS = 5;\n\tlet lastErr: unknown = null;\n\tfor (let i = 0; i < MATCHER_BOOT_ATTEMPTS; i++) {\n\t\ttry {\n\t\t\tawait refreshMatcher(db);\n\t\t\tlastErr = null;\n\t\t\tbreak;\n\t\t} catch (err) {\n\t\t\tlastErr = err;\n\t\t\tconst delayMs = 500 * 2 ** i; // 500ms, 1s, 2s, 4s, 8s\n\t\t\tlogger.warn(\"[emitter] matcher refresh failed, retrying\", {\n\t\t\t\tattempt: i + 1,\n\t\t\t\tdelayMs,\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\tawait new Promise((r) => setTimeout(r, delayMs));\n\t\t}\n\t}\n\tif (lastErr) {\n\t\tthrow new Error(\n\t\t\t`[emitter] matcher refresh failed ${MATCHER_BOOT_ATTEMPTS}×; aborting boot: ${\n\t\t\t\tlastErr instanceof Error ? lastErr.message : String(lastErr)\n\t\t\t}`,\n\t\t);\n\t}\n\n\t// LISTEN on new outbox + sub changes. Both channels fire on the TARGET DB\n\t// (webhook_outbox + webhooks are control-plane tables), so bind the\n\t// listener there — under the split it is NOT `DATABASE_URL`.\n\tconst listenUrl = targetListenerUrl();\n\tconst stopNew = await listen(\n\t\t\"webhooks:new_outbox\",\n\t\t() => {\n\t\t\tif (!state.running) return;\n\t\t\tvoid claimAndDrain(db, state, emitterId, \"notify\").catch((err) =>\n\t\t\t\tlogger.error(\"[emitter] claim failed\", {\n\t\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t\t}),\n\t\t\t);\n\t\t},\n\t\t{ connectionString: listenUrl },\n\t);\n\tconst stopChanged = await listen(\n\t\t\"webhooks:changed\",\n\t\t() => {\n\t\t\tif (!state.running) return;\n\t\t\tvoid refreshMatcher(db).catch((err) =>\n\t\t\t\tlogger.error(\"[emitter] matcher refresh failed\", {\n\t\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t\t}),\n\t\t\t);\n\t\t\t// A resume is exactly `webhooks:changed` — its rows were already\n\t\t\t// released back to claimable (see claimAndDispatchOnce's paused-sub\n\t\t\t// branch) as soon as we noticed the pause, so a resumed webhook's\n\t\t\t// backlog drains on the SAME event that flips it active again,\n\t\t\t// instead of waiting for an unrelated future notify or the poll.\n\t\t\tvoid claimAndDrain(db, state, emitterId, \"changed\").catch((err) =>\n\t\t\t\tlogger.error(\"[emitter] claim failed\", {\n\t\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t\t}),\n\t\t\t);\n\t\t},\n\t\t{ connectionString: listenUrl },\n\t);\n\t// Names both channels + the exact host/db LISTENed on (no credentials) so\n\t// a split-DB misconfiguration — the wake never reaching this process — is\n\t// visible in `docker logs` at boot, not only inferred from a delivery\n\t// latency graph later.\n\tlogger.info(\"[emitter] wake listeners connected\", {\n\t\tchannels: [\"webhooks:new_outbox\", \"webhooks:changed\"],\n\t\tdb: describeDbUrl(listenUrl),\n\t});\n\n\t// Poll every pollIntervalMs as a safety net for missed notifications +\n\t// backoff wakeups (rows whose next_attempt_at has passed).\n\tconst poll = setInterval(() => {\n\t\tif (!state.running) return;\n\t\tvoid claimAndDrain(db, state, emitterId, \"poll\").catch((err) =>\n\t\t\tlogger.error(\"[emitter] poll claim failed\", {\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t}),\n\t\t);\n\t}, pollIntervalMs);\n\n\t// Kick once on startup so any rows that arrived before we started drain.\n\tvoid claimAndDrain(db, state, emitterId, \"startup\");\n\n\t// Retention sweep — hourly by default.\n\tconst retention = setInterval(() => {\n\t\tif (!state.running) return;\n\t\tvoid runRetention(db).catch((err) =>\n\t\t\tlogger.error(\"[emitter] retention failed\", {\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t}),\n\t\t);\n\t}, retentionIntervalMs);\n\n\treturn async () => {\n\t\t// No new claims start (`runClaimLoop`'s own check) and no new\n\t\t// dispatches start (`pumpSub`'s own check) from this point on — the\n\t\t// two sets we're about to await can only shrink from here.\n\t\tstate.running = false;\n\t\tclearInterval(poll);\n\t\tclearInterval(retention);\n\t\tawait stopNew();\n\t\tawait stopChanged();\n\n\t\tconst pendingRuns = new Set(state.inFlightRuns);\n\t\tconst drainTarget = pendingRuns.size;\n\t\t// `runOne` never rejects (it catches into `settleFailed`), but a claim\n\t\t// pass could — swallow that here rather than let it crash shutdown; the\n\t\t// call site that kicked off the claim already logs its own errors.\n\t\tconst claimSettled = (state.currentClaim ?? Promise.resolve()).catch(\n\t\t\t() => {},\n\t\t);\n\t\tconst waitAll = Promise.all([claimSettled, ...pendingRuns]);\n\t\t// A dispatch can't legitimately run longer than the webhook timeout\n\t\t// ceiling — bound the wait so a hung receiver can't hold up shutdown\n\t\t// forever. Rows still claimed/locked at the deadline keep their lock\n\t\t// and are re-claimed by the next emitter after LOCK_WINDOW_MS.\n\t\tconst deadlineMs =\n\t\t\topts?.stopDrainDeadlineMs ?? MAX_WEBHOOK_TIMEOUT_MS + 5_000;\n\t\tlet timedOut = false;\n\t\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\t\tconst timeout = new Promise<void>((resolve) => {\n\t\t\ttimer = setTimeout(() => {\n\t\t\t\ttimedOut = true;\n\t\t\t\tresolve();\n\t\t\t}, deadlineMs);\n\t\t});\n\t\tawait Promise.race([waitAll, timeout]);\n\t\tclearTimeout(timer);\n\n\t\tconst abandoned = timedOut ? state.inFlightRuns.size : 0;\n\t\tconst drained = drainTarget - abandoned;\n\t\tlogger.info(\"[emitter] stopped\", {\n\t\t\tid: emitterId,\n\t\t\tevent: \"emitter_stopped\",\n\t\t\tdrained,\n\t\t\tabandoned,\n\t\t});\n\t};\n}\n",
6
6
  "import { signSecondlayerWebhook } from \"@secondlayer/shared/crypto/secondlayer-webhook\";\nimport type { Webhook, WebhookOutbox } from \"@secondlayer/shared/db\";\nimport { logger } from \"@secondlayer/shared/logger\";\nimport { buildCloudEvents } from \"./cloudevents.ts\";\nimport { buildCloudflare } from \"./cloudflare.ts\";\nimport { buildInngest } from \"./inngest.ts\";\nimport { buildRaw } from \"./raw.ts\";\nimport { buildStandardWebhooks } from \"./standard-webhooks.ts\";\nimport { buildTrigger } from \"./trigger.ts\";\n\nexport interface FormatBuildResult {\n\tbody: string;\n\theaders: Record<string, string>;\n}\n\nfunction buildBody(\n\toutboxRow: WebhookOutbox,\n\tsub: Webhook,\n\tsigningSecret: string,\n): FormatBuildResult {\n\tswitch (sub.format) {\n\t\tcase \"inngest\":\n\t\t\treturn buildInngest(outboxRow);\n\t\tcase \"trigger\":\n\t\t\treturn buildTrigger(outboxRow, sub);\n\t\tcase \"cloudflare\":\n\t\t\treturn buildCloudflare(outboxRow, sub);\n\t\tcase \"cloudevents\":\n\t\t\treturn buildCloudEvents(outboxRow, sub);\n\t\tcase \"raw\":\n\t\t\treturn buildRaw(outboxRow, sub);\n\t\tcase \"standard-webhooks\":\n\t\t\treturn buildStandardWebhooks(outboxRow, signingSecret);\n\t\tdefault:\n\t\t\tlogger.warn(\"Unknown webhook format, falling back to standard-webhooks\", {\n\t\t\t\tformat: sub.format,\n\t\t\t\twebhookId: sub.id,\n\t\t\t});\n\t\t\treturn buildStandardWebhooks(outboxRow, signingSecret);\n\t}\n}\n\n/**\n * Dispatch an outbox row through the format matching the webhook's\n * `format` column. Unknown formats fall back to `standard-webhooks` with\n * a warning log — receivers always get something deliverable.\n *\n * Every delivery, whatever its body shape, also gets the universal Secondlayer\n * authenticity headers (`webhook-id` + `X-Secondlayer-Signature`) so a receiver\n * can prove the payload came from us with one published public key — not just\n * the `standard-webhooks` format. The signature covers the exact body bytes\n * built here, so it must be attached after the body is final.\n */\nexport function buildForFormat(\n\toutboxRow: WebhookOutbox,\n\tsub: Webhook,\n\tsigningSecret: string,\n): FormatBuildResult {\n\tconst result = buildBody(outboxRow, sub, signingSecret);\n\tconst sigHeaders = signSecondlayerWebhook(outboxRow.id, result.body);\n\tif (sigHeaders) {\n\t\tresult.headers = { ...result.headers, ...sigHeaders };\n\t}\n\treturn result;\n}\n\nexport {\n\tbuildStandardWebhooks,\n\tbuildInngest,\n\tbuildTrigger,\n\tbuildCloudflare,\n\tbuildCloudEvents,\n\tbuildRaw,\n};\n",
7
7
  "import type { Webhook, WebhookOutbox } from \"@secondlayer/shared/db\";\n\n/**\n * CloudEvents 1.0 structured JSON — https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/formats/json-format.md\n *\n * Body shape:\n * {\n * \"specversion\": \"1.0\",\n * \"type\": \"<subgraph>.<table>.<created|updated|deleted>\",\n * \"source\": \"secondlayer:<subgraph_name>\",\n * \"id\": <outbox.id>, // UUID, used for dedup\n * \"time\": <ISO 8601>,\n * \"datacontenttype\": \"application/json\",\n * \"data\": { ...row }\n * }\n *\n * Content-Type is `application/cloudevents+json; charset=utf-8`. Binary\n * mode (headers as ce-*) isn't needed — structured mode is what every CE\n * SDK accepts out of the box.\n */\n\nexport function buildCloudEvents(\n\toutboxRow: WebhookOutbox,\n\t_sub: Webhook,\n): { body: string; headers: Record<string, string> } {\n\tconst event = {\n\t\tspecversion: \"1.0\",\n\t\ttype: outboxRow.event_type,\n\t\tsource: `secondlayer:${outboxRow.subgraph_name}`,\n\t\tid: outboxRow.id,\n\t\ttime: new Date(outboxRow.created_at).toISOString(),\n\t\tdatacontenttype: \"application/json\",\n\t\tdata: outboxRow.payload,\n\t};\n\treturn {\n\t\tbody: JSON.stringify(event),\n\t\theaders: {\n\t\t\t\"content-type\": \"application/cloudevents+json; charset=utf-8\",\n\t\t},\n\t};\n}\n",
8
8
  "import { decryptSecret } from \"@secondlayer/shared/crypto/secrets\";\nimport type { Webhook, WebhookOutbox } from \"@secondlayer/shared/db\";\n\n/**\n * Cloudflare Workflows — https://developers.cloudflare.com/workflows/build/events-and-parameters/\n *\n * POST https://api.cloudflare.com/client/v4/accounts/{account}/workflows/{name}/instances\n * Authorization: Bearer <CF_API_TOKEN>\n *\n * Body: `{ params }` — the workflow entrypoint receives this as the\n * `event.payload` object. We slip the outbox id into `params._outboxId`\n * so Workflows can dedupe on replays.\n */\n\nfunction resolveBearer(sub: Webhook): string | null {\n\tconst cfg = sub.auth_config as { token?: string; tokenEnc?: string };\n\tif (cfg.tokenEnc) {\n\t\t// Let decrypt errors propagate — see trigger.ts comment.\n\t\treturn decryptSecret(Buffer.from(cfg.tokenEnc, \"base64\"));\n\t}\n\treturn cfg.token ?? null;\n}\n\nexport function buildCloudflare(\n\toutboxRow: WebhookOutbox,\n\tsub: Webhook,\n): { body: string; headers: Record<string, string> } {\n\tconst body = JSON.stringify({\n\t\tparams: {\n\t\t\t...(outboxRow.payload as Record<string, unknown>),\n\t\t\t_type: outboxRow.event_type,\n\t\t\t_outboxId: outboxRow.id,\n\t\t},\n\t});\n\tconst headers: Record<string, string> = {\n\t\t\"content-type\": \"application/json\",\n\t};\n\tconst token = resolveBearer(sub);\n\tif (token) headers.authorization = `Bearer ${token}`;\n\treturn { body, headers };\n}\n",
@@ -14,7 +14,7 @@
14
14
  "import type { Database, Webhook } from \"@secondlayer/shared/db\";\nimport type { Kysely } from \"kysely\";\nimport { sql } from \"kysely\";\nimport { WebhookMatcher } from \"./emitter-matcher.ts\";\n\n/**\n * Singleton matcher populated at processor startup and hot-reloaded via\n * `pg_notify('webhooks:changed')`. The block-processor reads from it\n * to decide which outbox rows to emit for each flushed write.\n */\n\nexport const matcher = new WebhookMatcher();\n\nexport async function refreshMatcher(db: Kysely<Database>): Promise<number> {\n\tconst rows = await sql<Webhook>`\n\t\tSELECT * FROM webhooks WHERE status = 'active'\n\t`.execute(db);\n\tmatcher.setAll(rows.rows);\n\treturn matcher.size();\n}\n",
15
15
  "import type { Webhook } from \"@secondlayer/shared/db\";\n\n/**\n * Webhook matcher — holds the active-webhooks cache keyed by\n * `(subgraph_name, table_name)` and evaluates each sub's scalar-only\n * JSONB filter against a row.\n *\n * Filter DSL (v1): scalar operators over string/number/boolean columns.\n * { column: value } → column === value\n * { column: { eq: value } } → column === value\n * { column: { neq: value } } → column !== value\n * { column: { gt|gte|lt|lte: N } } → numeric compare\n * { column: { in: [a, b, c] } } → column ∈ set\n * { } → match all\n *\n * Nested objects, arrays, and non-scalar values are rejected with `false`\n * (never match) — subscribers get logged warnings at sub create time.\n *\n * Multiple conditions AND together. OR is deliberately absent in v1.\n */\n\nexport type FilterPrimitive = string | number | boolean;\nexport type FilterOperator =\n\t| { eq: FilterPrimitive }\n\t| { neq: FilterPrimitive }\n\t| { gt: number | string }\n\t| { gte: number | string }\n\t| { lt: number | string }\n\t| { lte: number | string }\n\t| { in: FilterPrimitive[] };\n\nexport type FilterClause = FilterPrimitive | FilterOperator;\nexport type WebhookFilter = Record<string, FilterClause>;\n\nfunction isPrimitive(v: unknown): v is FilterPrimitive {\n\tconst t = typeof v;\n\treturn t === \"string\" || t === \"number\" || t === \"boolean\";\n}\n\n/**\n * Coerce a value to BigInt for arbitrary-precision compare — avoids the\n * silent precision loss of `Number()` on integers past 2^53. Returns\n * `null` for non-integer numerics, which is fine because the filter DSL\n * is documented as scalar-only + integer-amount-aware (Stacks uses\n * uint128 bigint columns everywhere).\n */\nfunction coerceBigInt(v: unknown): bigint | null {\n\tif (typeof v === \"bigint\") return v;\n\tif (typeof v === \"number\") {\n\t\tif (!Number.isFinite(v)) return null;\n\t\tif (!Number.isInteger(v)) return null;\n\t\treturn BigInt(v);\n\t}\n\tif (typeof v === \"string\" && /^-?\\d+$/.test(v)) {\n\t\ttry {\n\t\t\treturn BigInt(v);\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Float compare fallback — used when a value isn't a clean integer\n * (rare in subgraph columns but defensible for user-supplied filters).\n */\nfunction coerceFloat(v: unknown): number | null {\n\tif (typeof v === \"number\" && Number.isFinite(v)) return v;\n\tif (typeof v === \"bigint\") return Number(v);\n\tif (typeof v === \"string\" && v !== \"\" && !Number.isNaN(Number(v))) {\n\t\treturn Number(v);\n\t}\n\treturn null;\n}\n\nfunction compareNumeric(a: unknown, b: unknown): 0 | 1 | -1 | null {\n\tconst ba = coerceBigInt(a);\n\tconst bb = coerceBigInt(b);\n\tif (ba !== null && bb !== null) {\n\t\tif (ba === bb) return 0;\n\t\treturn ba > bb ? 1 : -1;\n\t}\n\tconst fa = coerceFloat(a);\n\tconst fb = coerceFloat(b);\n\tif (fa === null || fb === null) return null;\n\tif (fa === fb) return 0;\n\treturn fa > fb ? 1 : -1;\n}\n\nfunction matchClause(rowValue: unknown, clause: FilterClause): boolean {\n\t// Reject non-primitive row values — `{ value: {...} }` should not\n\t// match `filter: { value: \"[object Object]\" }` via loose stringify.\n\t// Bigints are not primitives in the JSON sense but are supported\n\t// scalar inputs from subgraph rows.\n\tconst rowIsPrimitive = isPrimitive(rowValue) || typeof rowValue === \"bigint\";\n\n\tif (isPrimitive(clause)) {\n\t\tif (!rowIsPrimitive) return false;\n\t\tif (\n\t\t\ttypeof clause === \"number\" ||\n\t\t\ttypeof rowValue === \"number\" ||\n\t\t\ttypeof rowValue === \"bigint\"\n\t\t) {\n\t\t\tconst cmp = compareNumeric(rowValue, clause);\n\t\t\tif (cmp !== null) return cmp === 0;\n\t\t}\n\t\treturn rowValue === clause || String(rowValue) === String(clause);\n\t}\n\n\tif (clause === null || typeof clause !== \"object\" || Array.isArray(clause)) {\n\t\treturn false;\n\t}\n\n\tconst keys = Object.keys(clause);\n\tif (keys.length !== 1) return false;\n\tconst op = keys[0];\n\n\tconst c = clause as Record<string, unknown>;\n\tswitch (op) {\n\t\tcase \"eq\":\n\t\t\treturn matchClause(rowValue, c.eq as FilterPrimitive);\n\t\tcase \"neq\":\n\t\t\treturn !matchClause(rowValue, c.neq as FilterPrimitive);\n\t\tcase \"gt\":\n\t\tcase \"gte\":\n\t\tcase \"lt\":\n\t\tcase \"lte\": {\n\t\t\tif (!rowIsPrimitive) return false;\n\t\t\tconst cmp = compareNumeric(rowValue, c[op]);\n\t\t\tif (cmp === null) return false;\n\t\t\tif (op === \"gt\") return cmp > 0;\n\t\t\tif (op === \"gte\") return cmp >= 0;\n\t\t\tif (op === \"lt\") return cmp < 0;\n\t\t\treturn cmp <= 0;\n\t\t}\n\t\tcase \"in\": {\n\t\t\tconst list = c.in;\n\t\t\tif (!Array.isArray(list)) return false;\n\t\t\tif (!rowIsPrimitive) return false;\n\t\t\treturn list.some(\n\t\t\t\t(item) => isPrimitive(item) && matchClause(rowValue, item),\n\t\t\t);\n\t\t}\n\t\tdefault:\n\t\t\treturn false;\n\t}\n}\n\nexport function matchesFilter(\n\tfilter: WebhookFilter | null | undefined,\n\trow: Record<string, unknown>,\n): boolean {\n\tif (!filter || Object.keys(filter).length === 0) return true;\n\tfor (const [col, clause] of Object.entries(filter)) {\n\t\tif (!matchClause(row[col], clause)) return false;\n\t}\n\treturn true;\n}\n\n// ── Cache keyed by (subgraph_name, table_name) ──────────────────────────\n\ntype MatcherKey = string;\n\nfunction key(subgraphName: string, tableName: string): MatcherKey {\n\treturn `${subgraphName}\u0000${tableName}`;\n}\n\nexport class WebhookMatcher {\n\tprivate readonly byKey = new Map<MatcherKey, Webhook[]>();\n\tprivate readonly byId = new Map<string, Webhook>();\n\n\t/** Replace the entire cache with a fresh snapshot. */\n\tsetAll(subs: Webhook[]): void {\n\t\tthis.byKey.clear();\n\t\tthis.byId.clear();\n\t\tfor (const sub of subs) {\n\t\t\tif (sub.status !== \"active\") continue;\n\t\t\t// This matcher keys on (subgraph, table) row writes; chain\n\t\t\t// webhooks react to raw chain events and never flow through it.\n\t\t\tif (sub.kind !== \"subgraph\" || !sub.subgraph_name || !sub.table_name)\n\t\t\t\tcontinue;\n\t\t\tthis.byId.set(sub.id, sub);\n\t\t\tconst k = key(sub.subgraph_name, sub.table_name);\n\t\t\tconst arr = this.byKey.get(k);\n\t\t\tif (arr) arr.push(sub);\n\t\t\telse this.byKey.set(k, [sub]);\n\t\t}\n\t}\n\n\t/** Returns active subs for a (subgraph, table) whose filter matches the row. */\n\tmatch(\n\t\tsubgraphName: string,\n\t\ttableName: string,\n\t\trow: Record<string, unknown>,\n\t): Webhook[] {\n\t\tconst bucket = this.byKey.get(key(subgraphName, tableName));\n\t\tif (!bucket) return [];\n\t\tconst hits: Webhook[] = [];\n\t\tfor (const sub of bucket) {\n\t\t\tif (matchesFilter(sub.filter as WebhookFilter, row)) hits.push(sub);\n\t\t}\n\t\treturn hits;\n\t}\n\n\thas(subgraphName: string, tableName: string): boolean {\n\t\treturn this.byKey.has(key(subgraphName, tableName));\n\t}\n\n\tsize(): number {\n\t\treturn this.byId.size;\n\t}\n\n\tget(id: string): Webhook | undefined {\n\t\treturn this.byId.get(id);\n\t}\n}\n"
16
16
  ],
17
- "mappings": "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",
18
- "debugId": "4F50587CC3F4DDE664756E2164756E21",
17
+ "mappings": "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",
18
+ "debugId": "84298FAC2C66389B64756E2164756E21",
19
19
  "names": []
20
20
  }
@@ -28,7 +28,7 @@ interface BlockSource {
28
28
  /**
29
29
  * Highest canonical block height available to process.
30
30
  *
31
- * `opts` is an HTTP-plane long-poll hint (plan-063 3.4/3.5), ignored by any
31
+ * `opts` is an HTTP-plane long-poll hint, ignored by any
32
32
  * source with no such notion (the Postgres tap: a local DB read is already
33
33
  * instant, nothing to wait on). `knownHeight` is the last tip THIS caller
34
34
  * observed; passing `wait` without it is a no-op — the server needs a
@@ -4,7 +4,7 @@
4
4
  "sourcesContent": [
5
5
  "import { createHash } from \"node:crypto\";\nimport { type Database, getTargetDb } from \"@secondlayer/shared/db\";\nimport type { Webhook } from \"@secondlayer/shared/db\";\nimport { getWebhook } from \"@secondlayer/shared/db/queries/webhooks\";\nimport { logger } from \"@secondlayer/shared/logger\";\nimport { type Kysely, sql } from \"kysely\";\nimport { pgSchemaName as defaultSchemaName } from \"../schema/utils.ts\";\nimport {\n\ttype BlockSource,\n\tPublicApiBlockSource,\n\tbuildHttpClient,\n} from \"./block-source.ts\";\nimport {\n\tblockTimeOf,\n\tbuildSourcesMap,\n\tbuildTraitContracts,\n\temitChainOutbox,\n\temitSbtcOutbox,\n\tevaluateBlock,\n\treferencedEventTypes,\n} from \"./trigger-evaluator.ts\";\n\n/**\n * Replay historical subgraph rows as new outbox entries for a single\n * webhook. Rows are marked `is_replay=TRUE` so the emitter can\n * prioritize live deliveries (90/10 split) and the delivery log can\n * tag replays distinctly.\n *\n * Idempotency: `replayId` is deterministic over `(webhook_id,\n * fromBlock, toBlock)`, so re-running the same replay range is a no-op\n * thanks to the unique `(webhook_id, dedup_key)` constraint. A\n * user who actually wants to re-deliver the same range passes a\n * distinct `replayIdSuffix` (e.g. a timestamp) to get a fresh key.\n */\n\nconst BATCH_SIZE = 500;\n\n/** Default cap on blocks per replay call — overridable via\n * `WEBHOOK_REPLAY_MAX_BLOCKS` for an operator who wants a tighter (or, for a\n * trusted self-host, looser) limit. Read live, not baked in at import. */\nconst WEBHOOK_REPLAY_MAX_BLOCKS_DEFAULT = 100_000;\n\nfunction webhookReplayMaxBlocks(): number {\n\tconst raw = process.env.WEBHOOK_REPLAY_MAX_BLOCKS;\n\tif (raw === undefined) return WEBHOOK_REPLAY_MAX_BLOCKS_DEFAULT;\n\tconst parsed = Number.parseInt(raw, 10);\n\treturn Number.isFinite(parsed) && parsed > 0\n\t\t? parsed\n\t\t: WEBHOOK_REPLAY_MAX_BLOCKS_DEFAULT;\n}\n\n/** Renders a block count the way the original hardcoded message did\n * (\"100k blocks\") for round thousands, falling back to the raw number for a\n * ceiling that isn't a clean multiple of 1000. */\nfunction formatBlockCount(n: number): string {\n\treturn n % 1000 === 0 ? `${n / 1000}k` : `${n}`;\n}\n\n/**\n * One in-flight replay per webhook at a time. Replay is a synchronous scan\n * (the route awaits it fully before responding), so two concurrent requests\n * for the same webhook would otherwise interleave their scans against the\n * same dedup keyspace. In-memory per-process — replay already isn't\n * horizontally coordinated (see `BATCH_SIZE` keyset pagination above), so\n * this matches its existing single-process assumption.\n */\nconst inFlightReplays = new Set<string>();\n\n/** Thrown by {@link replayWebhook} when a replay is already running for the\n * webhook. The route maps this to 409; `force` does not bypass it — a forced\n * re-delivery still has to wait for the previous replay to finish. */\nexport class ReplayInProgressError extends Error {\n\tconstructor() {\n\t\tsuper(\"replay already in progress for this webhook\");\n\t\tthis.name = \"ReplayInProgressError\";\n\t}\n}\n\n/**\n * Test-only seam: directly mark (or clear) a webhook as having an in-flight\n * replay. Two real concurrent `replayWebhook` calls race the same DB fetch,\n * so which one wins the guard isn't deterministic in a test; this lets a test\n * exercise the guard (and the route's 409 mapping) without that race.\n * Production code never calls this.\n */\nexport function __setReplayInFlightForTest(\n\twebhookId: string,\n\tinFlight: boolean,\n): void {\n\tif (inFlight) inFlightReplays.add(webhookId);\n\telse inFlightReplays.delete(webhookId);\n}\n\nfunction replayDedupKey(\n\tsubgraphName: string,\n\ttableName: string,\n\trow: Record<string, unknown>,\n\treplayId: string,\n): string {\n\tconst canonical = `replay:${replayId}:${subgraphName}:${tableName}:${stableStringify(row)}`;\n\treturn createHash(\"sha256\").update(canonical).digest(\"hex\").slice(0, 32);\n}\n\nfunction stableStringify(obj: Record<string, unknown>): string {\n\tconst keys = Object.keys(obj).sort();\n\treturn JSON.stringify(\n\t\tkeys.reduce<Record<string, unknown>>((acc, k) => {\n\t\t\tacc[k] = obj[k];\n\t\t\treturn acc;\n\t\t}, {}),\n\t);\n}\n\nexport interface ReplayInput {\n\taccountId: string;\n\twebhookId: string;\n\tfromBlock: number;\n\ttoBlock: number;\n\t/** Force re-delivery by appending a unique suffix to the replay id. */\n\treplayIdSuffix?: string;\n}\n\nfunction deterministicReplayId(\n\twebhookId: string,\n\tfromBlock: number,\n\ttoBlock: number,\n\tsuffix?: string,\n): string {\n\tconst canonical = `${webhookId}:${fromBlock}:${toBlock}${suffix ? `:${suffix}` : \"\"}`;\n\treturn createHash(\"sha256\").update(canonical).digest(\"hex\").slice(0, 16);\n}\n\nexport interface ReplayResult {\n\treplayId: string;\n\tenqueuedCount: number;\n\tscannedCount: number;\n}\n\nasync function resolveSchemaName(\n\tdb: Kysely<Database>,\n\tsubgraphName: string,\n): Promise<string> {\n\tconst row = await db\n\t\t.selectFrom(\"subgraphs\")\n\t\t.select(\"schema_name\")\n\t\t.where(\"name\", \"=\", subgraphName)\n\t\t.executeTakeFirst();\n\tif (!row) {\n\t\tthrow new Error(\n\t\t\t`Subgraph \"${subgraphName}\" not registered — cannot replay its rows. Deploy the subgraph first.`,\n\t\t);\n\t}\n\treturn row.schema_name ?? defaultSchemaName(subgraphName);\n}\n\nexport async function replayWebhook(input: ReplayInput): Promise<ReplayResult> {\n\tif (input.fromBlock > input.toBlock) {\n\t\tthrow new Error(\"fromBlock must be <= toBlock\");\n\t}\n\tconst maxBlocks = webhookReplayMaxBlocks();\n\tif (input.toBlock - input.fromBlock > maxBlocks) {\n\t\tthrow new Error(\n\t\t\t`replay range exceeds ${formatBlockCount(maxBlocks)} blocks`,\n\t\t);\n\t}\n\n\tconst db = getTargetDb();\n\tconst sub = await getWebhook(db, input.accountId, input.webhookId);\n\tif (!sub) throw new Error(\"Webhook not found\");\n\n\tif (inFlightReplays.has(sub.id)) {\n\t\tthrow new ReplayInProgressError();\n\t}\n\tinFlightReplays.add(sub.id);\n\ttry {\n\t\t// Chain subs have no processed table — they react to raw chain events.\n\t\t// Replay re-runs the pure matcher over the canonical block range instead\n\t\t// of scanning rows.\n\t\tif (sub.kind === \"chain\") {\n\t\t\treturn await replayChainWebhook(db, sub, input);\n\t\t}\n\n\t\tconst subgraphName = sub.subgraph_name;\n\t\tconst tableName = sub.table_name;\n\t\tif (sub.kind !== \"subgraph\" || !subgraphName || !tableName) {\n\t\t\tthrow new Error(\n\t\t\t\t\"replay is only supported for subgraph or chain webhooks\",\n\t\t\t);\n\t\t}\n\n\t\tconst schema = await resolveSchemaName(db, subgraphName);\n\t\tconst replayId = deterministicReplayId(\n\t\t\tsub.id,\n\t\t\tinput.fromBlock,\n\t\t\tinput.toBlock,\n\t\t\tinput.replayIdSuffix,\n\t\t);\n\n\t\tlet scanned = 0;\n\t\tlet enqueued = 0;\n\t\t// Keyset pagination, not a positional skip-count: `_created_at` is written\n\t\t// as the literal NOW() (one value per transaction, so identical for every\n\t\t// row in a block), which makes (_block_height, _created_at) non-unique.\n\t\t// Paging by skip-count over a non-unique sort lets tied rows reorder\n\t\t// between pages and drop rows silently. `_id` is BIGSERIAL and total.\n\t\tlet lastId = 0n;\n\n\t\twhile (true) {\n\t\t\tconst { rows } = await sql<\n\t\t\t\tRecord<string, unknown>\n\t\t\t>`SELECT * FROM ${sql.raw(`\"${schema}\".\"${tableName}\"`)}\n\t\t\t\tWHERE _block_height >= ${sql.lit(input.fromBlock)}\n\t\t\t\t\tAND _block_height <= ${sql.lit(input.toBlock)}\n\t\t\t\t\tAND _id > ${sql.lit(lastId)}\n\t\t\t\tORDER BY _id ASC\n\t\t\t\tLIMIT ${sql.lit(BATCH_SIZE)}`.execute(db);\n\n\t\t\tif (rows.length === 0) break;\n\t\t\tscanned += rows.length;\n\n\t\t\tconst inserts = rows.map((row) => ({\n\t\t\t\twebhook_id: sub.id,\n\t\t\t\tsubgraph_name: subgraphName,\n\t\t\t\ttable_name: tableName,\n\t\t\t\tblock_height: Number(row._block_height),\n\t\t\t\ttx_id: (row._tx_id as string | undefined) ?? null,\n\t\t\t\trow_pk: {\n\t\t\t\t\tblockHeight: Number(row._block_height),\n\t\t\t\t\ttxId: row._tx_id ?? \"\",\n\t\t\t\t\treplayId,\n\t\t\t\t},\n\t\t\t\tevent_type: `${subgraphName}.${tableName}.replay`,\n\t\t\t\tpayload: row,\n\t\t\t\tdedup_key: replayDedupKey(subgraphName, tableName, row, replayId),\n\t\t\t\tis_replay: true,\n\t\t\t}));\n\n\t\t\tconst result = await db\n\t\t\t\t.insertInto(\"webhook_outbox\")\n\t\t\t\t.values(inserts)\n\t\t\t\t.onConflict((oc) => oc.columns([\"webhook_id\", \"dedup_key\"]).doNothing())\n\t\t\t\t.executeTakeFirst();\n\t\t\tenqueued += Number(result.numInsertedOrUpdatedRows ?? 0);\n\n\t\t\tlastId = BigInt(String(rows[rows.length - 1]?._id));\n\t\t\tif (rows.length < BATCH_SIZE) break;\n\t\t}\n\n\t\tlogger.info(\"Replay enqueued\", {\n\t\t\twebhook: sub.name,\n\t\t\treplayId,\n\t\t\tscanned,\n\t\t\tenqueued,\n\t\t\tfromBlock: input.fromBlock,\n\t\t\ttoBlock: input.toBlock,\n\t\t});\n\n\t\treturn { replayId, enqueuedCount: enqueued, scannedCount: scanned };\n\t} finally {\n\t\tinFlightReplays.delete(sub.id);\n\t}\n}\n\nconst CHAIN_REPLAY_BATCH = 200;\n\n/**\n * Replay a chain webhook by re-running the pure matcher over a historical\n * canonical block range and emitting fresh apply rows. Unlike subgraph replay\n * there is no processed table to scan — the matcher is range-driven, so we\n * reload canonical blocks off the public Index/Streams clock and re-match.\n *\n * Rows are emitted with `is_replay=TRUE` and replay-namespaced dedup keys, and —\n * critically — this never advances `trigger_evaluator_state`: replay is\n * historical and must not move the live forward cursor.\n */\nexport async function replayChainWebhook(\n\tdb: Kysely<Database>,\n\tsub: Webhook,\n\tinput: ReplayInput,\n\topts?: { source?: BlockSource },\n): Promise<ReplayResult> {\n\tconst replayId = deterministicReplayId(\n\t\tsub.id,\n\t\tinput.fromBlock,\n\t\tinput.toBlock,\n\t\tinput.replayIdSuffix,\n\t);\n\n\tconst { sources, keyMeta } = buildSourcesMap([sub]);\n\tconst source =\n\t\topts?.source ??\n\t\tnew PublicApiBlockSource(buildHttpClient(), referencedEventTypes([sub]));\n\n\tlet scanned = 0;\n\tlet enqueued = 0;\n\tfor (\n\t\tlet from = input.fromBlock;\n\t\tfrom <= input.toBlock;\n\t\tfrom += CHAIN_REPLAY_BATCH\n\t) {\n\t\tconst to = Math.min(from + CHAIN_REPLAY_BATCH - 1, input.toBlock);\n\t\tconst blocks = await source.loadBlockRange(from, to);\n\t\t// Trait membership only grows; resolve once per batch as of its top height.\n\t\tconst traitContracts = await buildTraitContracts([sub], to);\n\t\tfor (let h = from; h <= to; h++) {\n\t\t\tconst bd = blocks.get(h);\n\t\t\tif (!bd) continue;\n\t\t\tscanned++;\n\t\t\tconst blockTime = blockTimeOf(bd.block);\n\t\t\tconst matches = evaluateBlock(bd, sources, traitContracts);\n\t\t\tif (matches.length > 0) {\n\t\t\t\tenqueued += await emitChainOutbox(\n\t\t\t\t\tdb,\n\t\t\t\t\tmatches,\n\t\t\t\t\tkeyMeta,\n\t\t\t\t\th,\n\t\t\t\t\tbd.block.hash,\n\t\t\t\t\tblockTime,\n\t\t\t\t\t{\n\t\t\t\t\t\treplayId,\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t}\n\t\t\t// sBTC lifecycle triggers (deposit/withdrawal-create/accept/reject) match\n\t\t\t// against `sbtc_events`, not decoded_events — mirror the live loop so a\n\t\t\t// replay over a historical range backfills sBTC webhooks too. (Settlement\n\t\t\t// `swept_confirmed` is cursor/confirmed_at driven, not block-keyed, so it\n\t\t\t// emits nothing here — documented forward-only.)\n\t\t\tenqueued += await emitSbtcOutbox(db, [sub], h, bd.block.hash, blockTime, {\n\t\t\t\treplayId,\n\t\t\t});\n\t\t}\n\t}\n\n\tlogger.info(\"Chain replay enqueued\", {\n\t\twebhook: sub.name,\n\t\treplayId,\n\t\tscanned,\n\t\tenqueued,\n\t\tfromBlock: input.fromBlock,\n\t\ttoBlock: input.toBlock,\n\t});\n\n\treturn { replayId, enqueuedCount: enqueued, scannedCount: scanned };\n}\n",
6
6
  "// Re-export canonical pgSchemaName from shared\nexport { pgSchemaName } from \"@secondlayer/shared/db/queries/subgraphs\";\n\n/**\n * Double-quotes a Postgres identifier unless it's already a safe bare\n * identifier, escaping any embedded double quotes. Defense-in-depth for the\n * DDL emitters: every name reaching them (schema/table/column names, and the\n * composite names generator.ts builds from them) is validated upstream\n * (`SqlIdentifierSchema` / `SubgraphNameSchema`), so this is a no-op for every\n * currently-valid name — output stays byte-identical. It only changes\n * anything if a hostile name (embedded quote, semicolon, whitespace, ...)\n * somehow reached this far, turning it into an inert quoted identifier\n * instead of raw SQL.\n */\nexport function quotePgIdent(name: string): string {\n\tif (/^[a-z_][a-z0-9_]*$/i.test(name)) return name;\n\treturn `\"${name.replace(/\"/g, '\"\"')}\"`;\n}\n\n/** `user_account` → `userAccount`. Column and variable names in every emitted\n * ORM surface go through this. */\nexport function snakeToCamel(name: string): string {\n\treturn name.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());\n}\n\n/** `user_account` → `UserAccount`. Type/model names for one table must agree\n * across the Prisma, Drizzle, Kysely, and index-codegen emitters — a user can\n * import from more than one, so this lives here rather than per-generator. */\nexport function pascalCase(name: string): string {\n\tconst camel = snakeToCamel(name);\n\treturn camel.charAt(0).toUpperCase() + camel.slice(1);\n}\n",
7
- "import { getSourceDb } from \"@secondlayer/shared/db\";\nimport type { Transaction } from \"@secondlayer/shared/db\";\nimport {\n\ttype IndexEventRow,\n\ttype IndexHttpClient,\n\ttype IndexTransactionRow,\n\tcreateInternalIndexHttpClient,\n} from \"@secondlayer/shared/index-http\";\nimport { logger } from \"@secondlayer/shared/logger\";\nimport type { SubgraphDefinition, SubgraphFilter } from \"../types.ts\";\nimport { type BlockData, loadBlockRange } from \"./batch-loader.ts\";\nimport { ObserverHttpBlockSource } from \"./observer-http-source.ts\";\nimport {\n\treconstructBlock,\n\treconstructEvent,\n\treconstructTransaction,\n\treconstructTxFromEventRow,\n} from \"./reconstruct.ts\";\n\n/**\n * Where the subgraph runtime reads canonical chain data. Today it taps the\n * indexer Postgres directly (`PostgresBlockSource`); the re-point adds a\n * `PublicApiBlockSource` that consumes the Streams clock + Index data over\n * HTTP. `matchSources` / handlers / flush / outbox are unchanged — only the\n * loader + tip swap behind this seam.\n */\nexport interface BlockSource {\n\t/**\n\t * Highest canonical block height available to process.\n\t *\n\t * `opts` is an HTTP-plane long-poll hint (plan-063 3.4/3.5), ignored by any\n\t * source with no such notion (the Postgres tap: a local DB read is already\n\t * instant, nothing to wait on). `knownHeight` is the last tip THIS caller\n\t * observed; passing `wait` without it is a no-op — the server needs a\n\t * baseline to decide whether anything has changed.\n\t */\n\tgetTip(opts?: { wait?: number; knownHeight?: number }): Promise<number>;\n\t/** Canonical block data for [fromHeight, toHeight], keyed by height. */\n\tloadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>>;\n\t/** Sparse-scan probe: lowest height in (afterHeight, untilHeight] holding\n\t * an event this source's subgraph could match, or null when the rest of\n\t * the range is empty. Optional — only event-scoped sources support it. */\n\tnextDataHeight?(\n\t\tafterHeight: number,\n\t\tuntilHeight: number,\n\t): Promise<number | null>;\n\t/**\n\t * Per-decoder committed heights from the SAME response `getTip()` just\n\t * fetched (no extra request) — undefined for a source with no such notion\n\t * (the Postgres tap: local mode reads `decoder_checkpoints` directly and\n\t * never needs this). Lets a caller (the chain evaluator) narrow its own\n\t * bound to the decoders it actually reads instead of `getTip()`'s\n\t * conservative cross-decoder floor. Call AFTER `getTip()`.\n\t */\n\tgetDecodedHeights?(): Record<string, number | null> | undefined;\n}\n\n/** A (decoded event type, optional contract scope) pair the sparse probe\n * checks. Contract scoping is what makes token-subgraph reindexes leap over\n * everything that isn't their token. */\nexport type SparseProbeTarget = { eventType: string; contractId?: string };\n\n/** Sparse scanning is sound only when EVERY source is an event-type filter —\n * a contract_call/contract_deploy source matches transactions, which the\n * event probe can't see. */\nexport function canSparseScan(subgraph: SubgraphDefinition): boolean {\n\tif (Array.isArray(subgraph.sources)) return false;\n\tconst filters = sourceFilters(subgraph);\n\tif (filters.length === 0) return false;\n\treturn filters.every((f) => Boolean(EVENT_FILTER_TO_INDEX_TYPE[f.type]));\n}\n\n/** Probe targets for a subgraph's filters: decoded type + contract scope when\n * the filter pins one (assetIdentifier \"SP….contract::asset\" or contractId). */\nexport function sparseProbeTargets(\n\tsubgraph: SubgraphDefinition,\n): SparseProbeTarget[] {\n\tconst targets = new Map<string, SparseProbeTarget>();\n\tfor (const f of sourceFilters(subgraph)) {\n\t\tconst eventType = EVENT_FILTER_TO_INDEX_TYPE[f.type];\n\t\tif (!eventType) continue;\n\t\tconst scoped = f as { assetIdentifier?: string; contractId?: string };\n\t\tconst contractId =\n\t\t\tscoped.contractId ?? scoped.assetIdentifier?.split(\"::\")[0];\n\t\tconst key = `${eventType}|${contractId ?? \"\"}`;\n\t\ttargets.set(key, { eventType, ...(contractId ? { contractId } : {}) });\n\t}\n\treturn [...targets.values()];\n}\n\n/** Reads directly from the shared indexer Postgres (the original behavior). */\nexport class PostgresBlockSource implements BlockSource {\n\tasync getTip(): Promise<number> {\n\t\tconst progress = await getSourceDb()\n\t\t\t.selectFrom(\"index_progress\")\n\t\t\t.selectAll()\n\t\t\t.where(\"network\", \"=\", process.env.NETWORK ?? \"mainnet\")\n\t\t\t.executeTakeFirst();\n\t\treturn progress ? Number(progress.highest_seen_block) : 0;\n\t}\n\n\tloadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\treturn loadBlockRange(getSourceDb(), fromHeight, toHeight);\n\t}\n}\n\n// Subgraph source filter types that map to a decoded Index event_type. The\n// `_event` suffix is the runtime's raw form; print is keyed `print_event`.\nconst EVENT_FILTER_TO_INDEX_TYPE: Record<string, string> = {\n\tstx_transfer: \"stx_transfer\",\n\tstx_mint: \"stx_mint\",\n\tstx_burn: \"stx_burn\",\n\tstx_lock: \"stx_lock\",\n\tft_transfer: \"ft_transfer\",\n\tft_mint: \"ft_mint\",\n\tft_burn: \"ft_burn\",\n\tnft_transfer: \"nft_transfer\",\n\tnft_mint: \"nft_mint\",\n\tnft_burn: \"nft_burn\",\n\tprint_event: \"print\",\n\tnested_contract_call: \"nested_contract_call\",\n\tvar_set: \"var_set\",\n\tmap_set: \"map_set\",\n\tmap_insert: \"map_insert\",\n\tmap_delete: \"map_delete\",\n};\n\n// Tx-level source types — matched against /v1/index/transactions, not events.\nconst TX_SOURCE_TYPES = new Set([\"contract_call\", \"contract_deploy\"]);\n// Second clock (ordinal). Never part of a tx's classic event set: a\n// contract_call/contract_deploy source fetches every CLASSIC type, and vm\n// types are fetched only when a vm source names them.\nexport const VM_INDEX_EVENT_TYPES: ReadonlySet<string> = new Set([\n\t\"nested_contract_call\",\n\t\"var_set\",\n\t\"map_set\",\n\t\"map_insert\",\n\t\"map_delete\",\n]);\nconst CLASSIC_INDEX_EVENT_TYPES = [\n\t...new Set(Object.values(EVENT_FILTER_TO_INDEX_TYPE)),\n].filter((t) => !VM_INDEX_EVENT_TYPES.has(t));\n\nfunction sourceFilters(subgraph: SubgraphDefinition): SubgraphFilter[] {\n\tconst sources = subgraph.sources;\n\treturn Array.isArray(sources)\n\t\t? (sources as SubgraphFilter[])\n\t\t: Object.values(sources as Record<string, SubgraphFilter>);\n}\n\n/**\n * True when any source matches transactions (contract_call / contract_deploy):\n * the handler receives the full tx, so the loader must fetch real transactions.\n * Event-only subgraphs skip walkTransactions and synthesize the tx from joined\n * event context instead (the ~37x reindex over-fetch; see indexing-speed plan).\n */\nexport function needsTransactionData(subgraph: SubgraphDefinition): boolean {\n\treturn sourceFilters(subgraph).some((f) => TX_SOURCE_TYPES.has(f.type));\n}\n\n/** Build the (few) event-bearing txs from joined event context, one per tx_id —\n * the event-only replacement for draining every transaction in the range. */\nfunction synthesizeTxsFromEvents(events: IndexEventRow[]): Transaction[] {\n\tconst byId = new Map<string, Transaction>();\n\tfor (const e of events) {\n\t\tif (!byId.has(e.tx_id)) byId.set(e.tx_id, reconstructTxFromEventRow(e));\n\t}\n\treturn [...byId.values()];\n}\n\n/**\n * The Index event_types the loader must fetch for a set of source filter types.\n * A contract_call/contract_deploy source matches a tx and hands its FULL event\n * set to the handler, so when one is present we fetch every classic event type\n * (the matched tx's events must be complete); otherwise just the referenced\n * types. VM types ride a second clock and are fetched only when referenced.\n * Shared by the subgraph loader and the chain-trigger evaluator.\n */\nexport function indexEventTypesForFilterTypes(filterTypes: string[]): string[] {\n\tconst types = new Set<string>();\n\tif (filterTypes.some((t) => TX_SOURCE_TYPES.has(t))) {\n\t\tfor (const t of CLASSIC_INDEX_EVENT_TYPES) types.add(t);\n\t}\n\tfor (const t of filterTypes) {\n\t\tconst indexType = EVENT_FILTER_TO_INDEX_TYPE[t];\n\t\tif (indexType) types.add(indexType);\n\t}\n\treturn [...types];\n}\n\n/** Index event_types a subgraph's sources require. Empty when sources are absent. */\nexport function referencedIndexEventTypes(\n\tsubgraph: SubgraphDefinition,\n): string[] {\n\tif (!subgraph.sources) return [];\n\treturn indexEventTypesForFilterTypes(\n\t\tsourceFilters(subgraph).map((f) => f.type),\n\t);\n}\n\n/**\n * streams-index eligibility: every source must be a known event-type or\n * contract_call/contract_deploy filter (no array-style sources, which leak the\n * unreconstructable `_eventId`). Trait scope IS allowed — trait resolution\n * reads the contract registry on the platform DB (`targetDb`), which the\n * processor always holds, so it's source-independent. Everything else stays on\n * the DB tap.\n */\nexport function isStreamsIndexEligible(subgraph: SubgraphDefinition): boolean {\n\tif (Array.isArray(subgraph.sources)) return false;\n\tconst filters = sourceFilters(subgraph);\n\tif (filters.length === 0) return false;\n\tfor (const f of filters) {\n\t\tconst known =\n\t\t\tEVENT_FILTER_TO_INDEX_TYPE[f.type] || TX_SOURCE_TYPES.has(f.type);\n\t\tif (!known) return false;\n\t}\n\treturn true;\n}\n\n/** Streams clock + Index data plane, reconstructed into raw BlockData rows. */\nexport class PublicApiBlockSource implements BlockSource {\n\tconstructor(\n\t\tprivate readonly http: IndexHttpClient,\n\t\tprivate readonly eventTypes: string[],\n\t\t/** When set, enables the sparse-scan probe (event-scoped subgraphs). */\n\t\tprivate readonly probeTargets?: SparseProbeTarget[],\n\t\t/** False for event-only subgraphs → skip walkTransactions, synthesize the\n\t\t * tx from joined event context. Defaults true (safe / unchanged). */\n\t\tprivate readonly needsTransactions = true,\n\t) {}\n\n\t/** Lowest height in (after, until] any probe target hits, or null. */\n\tasync nextDataHeight(\n\t\tafterHeight: number,\n\t\tuntilHeight: number,\n\t): Promise<number | null> {\n\t\tif (!this.probeTargets?.length) return afterHeight + 1;\n\t\tconst hits = await Promise.all(\n\t\t\tthis.probeTargets.map((t) =>\n\t\t\t\tthis.http.firstEventHeight(\n\t\t\t\t\tt.eventType,\n\t\t\t\t\tafterHeight + 1,\n\t\t\t\t\tuntilHeight,\n\t\t\t\t\tt.contractId,\n\t\t\t\t),\n\t\t\t),\n\t\t);\n\t\tconst found = hits.filter((h): h is number => h !== null);\n\t\treturn found.length ? Math.min(...found) : null;\n\t}\n\n\tgetTip(opts?: { wait?: number; knownHeight?: number }): Promise<number> {\n\t\t// VM rows land with ingest. Decoded Index tip can lag; use the source\n\t\t// field when this loader fetches any vm type.\n\t\tif (this.eventTypes.some((t) => VM_INDEX_EVENT_TYPES.has(t))) {\n\t\t\treturn this.http.getIndexSourceTip(opts);\n\t\t}\n\t\t// Scope the tip (and, with `wait`, what counts as \"nothing new\") to\n\t\t// exactly the classic types THIS source reads instead of the global\n\t\t// cross-decoder floor. Without this, any of the ~15 classic decoders\n\t\t// committing — most unrelated to what this source actually walks —\n\t\t// moves the global floor and makes an unrelated `wait` return early.\n\t\treturn this.http.getIndexTip({ ...opts, eventTypes: this.eventTypes });\n\t}\n\n\t/** Reads the SAME envelope `getTip()` just cached on `this.http` — call\n\t * this only after `getTip()` has resolved at least once. */\n\tgetDecodedHeights(): Record<string, number | null> | undefined {\n\t\treturn this.http.getDecodedHeights();\n\t}\n\n\tasync loadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\t// Event-only subgraphs join tx context onto events (withTx) and skip the\n\t\t// walkTransactions over-fetch entirely; tx-level sources fetch real txs.\n\t\tconst withTx = !this.needsTransactions;\n\t\tconst [blocks, txRows, eventLists] = await Promise.all([\n\t\t\tthis.http.walkBlocks(fromHeight, toHeight),\n\t\t\tthis.needsTransactions\n\t\t\t\t? this.http.walkTransactions(fromHeight, toHeight)\n\t\t\t\t: Promise.resolve<IndexTransactionRow[]>([]),\n\t\t\tPromise.all(\n\t\t\t\tthis.eventTypes.map((t) =>\n\t\t\t\t\tthis.http.walkEvents(t, fromHeight, toHeight, withTx),\n\t\t\t\t),\n\t\t\t),\n\t\t]);\n\n\t\tconst map = new Map<number, BlockData>();\n\t\t// Seed every canonical height (incl. empty blocks) so catch-up doesn't\n\t\t// file them as gaps.\n\t\tfor (const b of blocks) {\n\t\t\tmap.set(b.block_height, {\n\t\t\t\tblock: reconstructBlock(b),\n\t\t\t\ttxs: [],\n\t\t\t\tevents: [],\n\t\t\t});\n\t\t}\n\t\t// For event-only subgraphs, materialize only the event-bearing txs from\n\t\t// joined event context instead of every transaction in the range.\n\t\tconst txs = this.needsTransactions\n\t\t\t? txRows.map(reconstructTransaction)\n\t\t\t: synthesizeTxsFromEvents(eventLists.flat());\n\t\tfor (const t of txs) {\n\t\t\tmap.get(t.block_height)?.txs.push(t);\n\t\t}\n\t\tfor (const list of eventLists) {\n\t\t\tfor (const e of list) {\n\t\t\t\tconst bd = map.get(e.block_height);\n\t\t\t\tif (!bd) continue;\n\t\t\t\t// vm rows ride their own clock: never merged into `events`, whose\n\t\t\t\t// order is classic event_index.\n\t\t\t\tif (VM_INDEX_EVENT_TYPES.has(e.event_type)) {\n\t\t\t\t\tbd.vmEvents ??= [];\n\t\t\t\t\tbd.vmEvents.push(reconstructEvent(e));\n\t\t\t\t} else {\n\t\t\t\t\tbd.events.push(reconstructEvent(e));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Canonical ordering — multi-type event walks merge here, per clock.\n\t\tfor (const bd of map.values()) {\n\t\t\tbd.txs.sort((a, b) => a.tx_index - b.tx_index);\n\t\t\tbd.events.sort((a, b) => a.event_index - b.event_index);\n\t\t\tbd.vmEvents?.sort((a, b) => a.event_index - b.event_index);\n\t\t}\n\t\treturn map;\n\t}\n}\n\n/**\n * Wraps a primary source and falls back to a secondary, per call, when the\n * primary throws. Use it to make the HTTP (Streams+Index) plane a SOFT dependency:\n * if api is unavailable, the processor reads the Postgres tap and keeps advancing\n * instead of stalling. Safe to mix mid-stream — both taps read the same canonical\n * chain at the same heights and the cursor is forward-only. Stateless (no breaker)\n * so it's failover-safe across replicas; the primary is retried every call and\n * resumes transparently once healthy.\n */\nexport class FallbackBlockSource implements BlockSource {\n\t/** True when the LAST `getTip()` call actually used the primary — guards\n\t * `getDecodedHeights()` against returning the primary's stale cached\n\t * envelope from an earlier success after a call that just fell back. */\n\tprivate lastTipFromPrimary = false;\n\n\tconstructor(\n\t\tprivate readonly primary: BlockSource,\n\t\tprivate readonly fallback: BlockSource,\n\t) {}\n\n\tasync getTip(opts?: {\n\t\twait?: number;\n\t\tknownHeight?: number;\n\t}): Promise<number> {\n\t\ttry {\n\t\t\tconst tip = await this.primary.getTip(opts);\n\t\t\tthis.lastTipFromPrimary = true;\n\t\t\treturn tip;\n\t\t} catch (err) {\n\t\t\tlogger.warn(\"block source primary getTip failed — using DB tap\", {\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\tthis.lastTipFromPrimary = false;\n\t\t\t// The DB tap has no wait/long-poll notion — a plain read is already\n\t\t\t// instant, so `opts` is dropped here rather than forwarded.\n\t\t\treturn this.fallback.getTip();\n\t\t}\n\t}\n\n\t/** Only meaningful right after a `getTip()` that used the primary — the\n\t * fallback (Postgres tap) has no such notion, and returning the\n\t * primary's stale cache after a fallback would mismatch the returned\n\t * tip. Local mode's own `decoderBoundTip` never calls this. */\n\tgetDecodedHeights(): Record<string, number | null> | undefined {\n\t\tif (!this.lastTipFromPrimary) return undefined;\n\t\treturn this.primary.getDecodedHeights?.();\n\t}\n\n\tasync loadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\ttry {\n\t\t\treturn await this.primary.loadBlockRange(fromHeight, toHeight);\n\t\t} catch (err) {\n\t\t\tlogger.warn(\"block source primary loadBlockRange failed — using DB tap\", {\n\t\t\t\tfrom: fromHeight,\n\t\t\t\tto: toHeight,\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\treturn this.fallback.loadBlockRange(fromHeight, toHeight);\n\t\t}\n\t}\n\n\t/** Probe via the primary only; a probe failure just means \"no skip\" —\n\t * the caller falls back to plain batch advancement. */\n\tasync nextDataHeight(\n\t\tafterHeight: number,\n\t\tuntilHeight: number,\n\t): Promise<number | null> {\n\t\tif (!this.primary.nextDataHeight) return afterHeight + 1;\n\t\ttry {\n\t\t\treturn await this.primary.nextDataHeight(afterHeight, untilHeight);\n\t\t} catch {\n\t\t\treturn afterHeight + 1;\n\t\t}\n\t}\n}\n\nconst postgresBlockSource = new PostgresBlockSource();\n\n/**\n * HTTP (Streams+Index) chain source for a set of decoded event types, wrapped so\n * it falls back to the Postgres tap when api is down. Used by the chain-trigger\n * evaluator (which isn't subgraph-scoped, so it can't go through resolveBlockSource).\n *\n * `needsTransactions` defaults true (safe/unchanged) but the evaluator passes\n * `chainSubsNeedTransactions(chainSubs)` so a tick with no contract_call/deploy\n * trigger skips `walkTransactions` entirely — one fewer HTTP round trip and a\n * smaller `walkEvents` payload (no `tx_context` join) on every such tick.\n *\n * `httpClient` defaults to a fresh client (unchanged behavior) — pass one in\n * to reuse across calls. The evaluator's own long-lived loop does this so\n * `IndexHttpClient.waitIsSupported()` (plan-063 3.5) reflects what THIS\n * server actually supports instead of resetting every tick.\n */\nexport function buildChainBlockSource(\n\teventTypes: string[],\n\tneedsTransactions = true,\n\thttpClient: IndexHttpClient = buildHttpClient(),\n): BlockSource {\n\treturn new FallbackBlockSource(\n\t\tnew PublicApiBlockSource(\n\t\t\thttpClient,\n\t\t\teventTypes,\n\t\t\tundefined,\n\t\t\tneedsTransactions,\n\t\t),\n\t\tpostgresBlockSource,\n\t);\n}\n\nexport function buildHttpClient(): IndexHttpClient {\n\treturn createInternalIndexHttpClient();\n}\n\n/**\n * Resolve the block source for a subgraph. `SUBGRAPH_SOURCE=streams-index`\n * opts eligible subgraphs onto the public Streams clock + Index data;\n * `SUBGRAPH_SOURCE=observer-http` pages internal observer-events (experimental).\n * Default stays on the Postgres tap.\n */\nexport function resolveBlockSource(subgraph?: SubgraphDefinition): BlockSource {\n\tif (process.env.SUBGRAPH_SOURCE === \"observer-http\") {\n\t\tconst baseUrl = process.env.OBSERVER_HTTP_URL;\n\t\tif (!baseUrl) {\n\t\t\tthrow new Error(\n\t\t\t\t\"SUBGRAPH_SOURCE=observer-http requires OBSERVER_HTTP_URL\",\n\t\t\t);\n\t\t}\n\t\treturn new ObserverHttpBlockSource({\n\t\t\tbaseUrl,\n\t\t\ttoken: process.env.OBSERVER_HTTP_EXPORT_TOKEN || null,\n\t\t});\n\t}\n\tif (\n\t\tprocess.env.SUBGRAPH_SOURCE === \"streams-index\" &&\n\t\tsubgraph &&\n\t\tisStreamsIndexEligible(subgraph)\n\t) {\n\t\t// Soft-depend on api: fall back to the Postgres tap per-call if the HTTP\n\t\t// plane is down, so the processor keeps advancing instead of stalling.\n\t\treturn new FallbackBlockSource(\n\t\t\tnew PublicApiBlockSource(\n\t\t\t\tbuildHttpClient(),\n\t\t\t\treferencedIndexEventTypes(subgraph),\n\t\t\t\tcanSparseScan(subgraph) ? sparseProbeTargets(subgraph) : undefined,\n\t\t\t\tneedsTransactionData(subgraph),\n\t\t\t),\n\t\t\tpostgresBlockSource,\n\t\t);\n\t}\n\tif (process.env.SUBGRAPH_SOURCE === \"streams-index\" && subgraph) {\n\t\tlogger.debug(\"Subgraph not streams-index eligible, using DB tap\", {\n\t\t\tsubgraph: subgraph.name,\n\t\t});\n\t}\n\treturn postgresBlockSource;\n}\n",
7
+ "import { getSourceDb } from \"@secondlayer/shared/db\";\nimport type { Transaction } from \"@secondlayer/shared/db\";\nimport {\n\ttype IndexEventRow,\n\ttype IndexHttpClient,\n\ttype IndexTransactionRow,\n\tcreateInternalIndexHttpClient,\n} from \"@secondlayer/shared/index-http\";\nimport { logger } from \"@secondlayer/shared/logger\";\nimport type { SubgraphDefinition, SubgraphFilter } from \"../types.ts\";\nimport { type BlockData, loadBlockRange } from \"./batch-loader.ts\";\nimport { ObserverHttpBlockSource } from \"./observer-http-source.ts\";\nimport {\n\treconstructBlock,\n\treconstructEvent,\n\treconstructTransaction,\n\treconstructTxFromEventRow,\n} from \"./reconstruct.ts\";\n\n/**\n * Where the subgraph runtime reads canonical chain data. Today it taps the\n * indexer Postgres directly (`PostgresBlockSource`); the re-point adds a\n * `PublicApiBlockSource` that consumes the Streams clock + Index data over\n * HTTP. `matchSources` / handlers / flush / outbox are unchanged — only the\n * loader + tip swap behind this seam.\n */\nexport interface BlockSource {\n\t/**\n\t * Highest canonical block height available to process.\n\t *\n\t * `opts` is an HTTP-plane long-poll hint, ignored by any\n\t * source with no such notion (the Postgres tap: a local DB read is already\n\t * instant, nothing to wait on). `knownHeight` is the last tip THIS caller\n\t * observed; passing `wait` without it is a no-op — the server needs a\n\t * baseline to decide whether anything has changed.\n\t */\n\tgetTip(opts?: { wait?: number; knownHeight?: number }): Promise<number>;\n\t/** Canonical block data for [fromHeight, toHeight], keyed by height. */\n\tloadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>>;\n\t/** Sparse-scan probe: lowest height in (afterHeight, untilHeight] holding\n\t * an event this source's subgraph could match, or null when the rest of\n\t * the range is empty. Optional — only event-scoped sources support it. */\n\tnextDataHeight?(\n\t\tafterHeight: number,\n\t\tuntilHeight: number,\n\t): Promise<number | null>;\n\t/**\n\t * Per-decoder committed heights from the SAME response `getTip()` just\n\t * fetched (no extra request) — undefined for a source with no such notion\n\t * (the Postgres tap: local mode reads `decoder_checkpoints` directly and\n\t * never needs this). Lets a caller (the chain evaluator) narrow its own\n\t * bound to the decoders it actually reads instead of `getTip()`'s\n\t * conservative cross-decoder floor. Call AFTER `getTip()`.\n\t */\n\tgetDecodedHeights?(): Record<string, number | null> | undefined;\n}\n\n/** A (decoded event type, optional contract scope) pair the sparse probe\n * checks. Contract scoping is what makes token-subgraph reindexes leap over\n * everything that isn't their token. */\nexport type SparseProbeTarget = { eventType: string; contractId?: string };\n\n/** Sparse scanning is sound only when EVERY source is an event-type filter —\n * a contract_call/contract_deploy source matches transactions, which the\n * event probe can't see. */\nexport function canSparseScan(subgraph: SubgraphDefinition): boolean {\n\tif (Array.isArray(subgraph.sources)) return false;\n\tconst filters = sourceFilters(subgraph);\n\tif (filters.length === 0) return false;\n\treturn filters.every((f) => Boolean(EVENT_FILTER_TO_INDEX_TYPE[f.type]));\n}\n\n/** Probe targets for a subgraph's filters: decoded type + contract scope when\n * the filter pins one (assetIdentifier \"SP….contract::asset\" or contractId). */\nexport function sparseProbeTargets(\n\tsubgraph: SubgraphDefinition,\n): SparseProbeTarget[] {\n\tconst targets = new Map<string, SparseProbeTarget>();\n\tfor (const f of sourceFilters(subgraph)) {\n\t\tconst eventType = EVENT_FILTER_TO_INDEX_TYPE[f.type];\n\t\tif (!eventType) continue;\n\t\tconst scoped = f as { assetIdentifier?: string; contractId?: string };\n\t\tconst contractId =\n\t\t\tscoped.contractId ?? scoped.assetIdentifier?.split(\"::\")[0];\n\t\tconst key = `${eventType}|${contractId ?? \"\"}`;\n\t\ttargets.set(key, { eventType, ...(contractId ? { contractId } : {}) });\n\t}\n\treturn [...targets.values()];\n}\n\n/** Reads directly from the shared indexer Postgres (the original behavior). */\nexport class PostgresBlockSource implements BlockSource {\n\tasync getTip(): Promise<number> {\n\t\tconst progress = await getSourceDb()\n\t\t\t.selectFrom(\"index_progress\")\n\t\t\t.selectAll()\n\t\t\t.where(\"network\", \"=\", process.env.NETWORK ?? \"mainnet\")\n\t\t\t.executeTakeFirst();\n\t\treturn progress ? Number(progress.highest_seen_block) : 0;\n\t}\n\n\tloadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\treturn loadBlockRange(getSourceDb(), fromHeight, toHeight);\n\t}\n}\n\n// Subgraph source filter types that map to a decoded Index event_type. The\n// `_event` suffix is the runtime's raw form; print is keyed `print_event`.\nconst EVENT_FILTER_TO_INDEX_TYPE: Record<string, string> = {\n\tstx_transfer: \"stx_transfer\",\n\tstx_mint: \"stx_mint\",\n\tstx_burn: \"stx_burn\",\n\tstx_lock: \"stx_lock\",\n\tft_transfer: \"ft_transfer\",\n\tft_mint: \"ft_mint\",\n\tft_burn: \"ft_burn\",\n\tnft_transfer: \"nft_transfer\",\n\tnft_mint: \"nft_mint\",\n\tnft_burn: \"nft_burn\",\n\tprint_event: \"print\",\n\tnested_contract_call: \"nested_contract_call\",\n\tvar_set: \"var_set\",\n\tmap_set: \"map_set\",\n\tmap_insert: \"map_insert\",\n\tmap_delete: \"map_delete\",\n};\n\n// Tx-level source types — matched against /v1/index/transactions, not events.\nconst TX_SOURCE_TYPES = new Set([\"contract_call\", \"contract_deploy\"]);\n// Second clock (ordinal). Never part of a tx's classic event set: a\n// contract_call/contract_deploy source fetches every CLASSIC type, and vm\n// types are fetched only when a vm source names them.\nexport const VM_INDEX_EVENT_TYPES: ReadonlySet<string> = new Set([\n\t\"nested_contract_call\",\n\t\"var_set\",\n\t\"map_set\",\n\t\"map_insert\",\n\t\"map_delete\",\n]);\nconst CLASSIC_INDEX_EVENT_TYPES = [\n\t...new Set(Object.values(EVENT_FILTER_TO_INDEX_TYPE)),\n].filter((t) => !VM_INDEX_EVENT_TYPES.has(t));\n\nfunction sourceFilters(subgraph: SubgraphDefinition): SubgraphFilter[] {\n\tconst sources = subgraph.sources;\n\treturn Array.isArray(sources)\n\t\t? (sources as SubgraphFilter[])\n\t\t: Object.values(sources as Record<string, SubgraphFilter>);\n}\n\n/**\n * True when any source matches transactions (contract_call / contract_deploy):\n * the handler receives the full tx, so the loader must fetch real transactions.\n * Event-only subgraphs skip walkTransactions and synthesize the tx from joined\n * event context instead (the ~37x reindex over-fetch; see indexing-speed plan).\n */\nexport function needsTransactionData(subgraph: SubgraphDefinition): boolean {\n\treturn sourceFilters(subgraph).some((f) => TX_SOURCE_TYPES.has(f.type));\n}\n\n/** Build the (few) event-bearing txs from joined event context, one per tx_id —\n * the event-only replacement for draining every transaction in the range. */\nfunction synthesizeTxsFromEvents(events: IndexEventRow[]): Transaction[] {\n\tconst byId = new Map<string, Transaction>();\n\tfor (const e of events) {\n\t\tif (!byId.has(e.tx_id)) byId.set(e.tx_id, reconstructTxFromEventRow(e));\n\t}\n\treturn [...byId.values()];\n}\n\n/**\n * The Index event_types the loader must fetch for a set of source filter types.\n * A contract_call/contract_deploy source matches a tx and hands its FULL event\n * set to the handler, so when one is present we fetch every classic event type\n * (the matched tx's events must be complete); otherwise just the referenced\n * types. VM types ride a second clock and are fetched only when referenced.\n * Shared by the subgraph loader and the chain-trigger evaluator.\n */\nexport function indexEventTypesForFilterTypes(filterTypes: string[]): string[] {\n\tconst types = new Set<string>();\n\tif (filterTypes.some((t) => TX_SOURCE_TYPES.has(t))) {\n\t\tfor (const t of CLASSIC_INDEX_EVENT_TYPES) types.add(t);\n\t}\n\tfor (const t of filterTypes) {\n\t\tconst indexType = EVENT_FILTER_TO_INDEX_TYPE[t];\n\t\tif (indexType) types.add(indexType);\n\t}\n\treturn [...types];\n}\n\n/** Index event_types a subgraph's sources require. Empty when sources are absent. */\nexport function referencedIndexEventTypes(\n\tsubgraph: SubgraphDefinition,\n): string[] {\n\tif (!subgraph.sources) return [];\n\treturn indexEventTypesForFilterTypes(\n\t\tsourceFilters(subgraph).map((f) => f.type),\n\t);\n}\n\n/**\n * streams-index eligibility: every source must be a known event-type or\n * contract_call/contract_deploy filter (no array-style sources, which leak the\n * unreconstructable `_eventId`). Trait scope IS allowed — trait resolution\n * reads the contract registry on the platform DB (`targetDb`), which the\n * processor always holds, so it's source-independent. Everything else stays on\n * the DB tap.\n */\nexport function isStreamsIndexEligible(subgraph: SubgraphDefinition): boolean {\n\tif (Array.isArray(subgraph.sources)) return false;\n\tconst filters = sourceFilters(subgraph);\n\tif (filters.length === 0) return false;\n\tfor (const f of filters) {\n\t\tconst known =\n\t\t\tEVENT_FILTER_TO_INDEX_TYPE[f.type] || TX_SOURCE_TYPES.has(f.type);\n\t\tif (!known) return false;\n\t}\n\treturn true;\n}\n\n/** Streams clock + Index data plane, reconstructed into raw BlockData rows. */\nexport class PublicApiBlockSource implements BlockSource {\n\tconstructor(\n\t\tprivate readonly http: IndexHttpClient,\n\t\tprivate readonly eventTypes: string[],\n\t\t/** When set, enables the sparse-scan probe (event-scoped subgraphs). */\n\t\tprivate readonly probeTargets?: SparseProbeTarget[],\n\t\t/** False for event-only subgraphs → skip walkTransactions, synthesize the\n\t\t * tx from joined event context. Defaults true (safe / unchanged). */\n\t\tprivate readonly needsTransactions = true,\n\t) {}\n\n\t/** Lowest height in (after, until] any probe target hits, or null. */\n\tasync nextDataHeight(\n\t\tafterHeight: number,\n\t\tuntilHeight: number,\n\t): Promise<number | null> {\n\t\tif (!this.probeTargets?.length) return afterHeight + 1;\n\t\tconst hits = await Promise.all(\n\t\t\tthis.probeTargets.map((t) =>\n\t\t\t\tthis.http.firstEventHeight(\n\t\t\t\t\tt.eventType,\n\t\t\t\t\tafterHeight + 1,\n\t\t\t\t\tuntilHeight,\n\t\t\t\t\tt.contractId,\n\t\t\t\t),\n\t\t\t),\n\t\t);\n\t\tconst found = hits.filter((h): h is number => h !== null);\n\t\treturn found.length ? Math.min(...found) : null;\n\t}\n\n\tgetTip(opts?: { wait?: number; knownHeight?: number }): Promise<number> {\n\t\t// VM rows land with ingest. Decoded Index tip can lag; use the source\n\t\t// field when this loader fetches any vm type.\n\t\tif (this.eventTypes.some((t) => VM_INDEX_EVENT_TYPES.has(t))) {\n\t\t\treturn this.http.getIndexSourceTip(opts);\n\t\t}\n\t\t// Scope the tip (and, with `wait`, what counts as \"nothing new\") to\n\t\t// exactly the classic types THIS source reads instead of the global\n\t\t// cross-decoder floor. Without this, any of the ~15 classic decoders\n\t\t// committing — most unrelated to what this source actually walks —\n\t\t// moves the global floor and makes an unrelated `wait` return early.\n\t\treturn this.http.getIndexTip({ ...opts, eventTypes: this.eventTypes });\n\t}\n\n\t/** Reads the SAME envelope `getTip()` just cached on `this.http` — call\n\t * this only after `getTip()` has resolved at least once. */\n\tgetDecodedHeights(): Record<string, number | null> | undefined {\n\t\treturn this.http.getDecodedHeights();\n\t}\n\n\tasync loadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\t// Event-only subgraphs join tx context onto events (withTx) and skip the\n\t\t// walkTransactions over-fetch entirely; tx-level sources fetch real txs.\n\t\tconst withTx = !this.needsTransactions;\n\t\tconst [blocks, txRows, eventLists] = await Promise.all([\n\t\t\tthis.http.walkBlocks(fromHeight, toHeight),\n\t\t\tthis.needsTransactions\n\t\t\t\t? this.http.walkTransactions(fromHeight, toHeight)\n\t\t\t\t: Promise.resolve<IndexTransactionRow[]>([]),\n\t\t\tPromise.all(\n\t\t\t\tthis.eventTypes.map((t) =>\n\t\t\t\t\tthis.http.walkEvents(t, fromHeight, toHeight, withTx),\n\t\t\t\t),\n\t\t\t),\n\t\t]);\n\n\t\tconst map = new Map<number, BlockData>();\n\t\t// Seed every canonical height (incl. empty blocks) so catch-up doesn't\n\t\t// file them as gaps.\n\t\tfor (const b of blocks) {\n\t\t\tmap.set(b.block_height, {\n\t\t\t\tblock: reconstructBlock(b),\n\t\t\t\ttxs: [],\n\t\t\t\tevents: [],\n\t\t\t});\n\t\t}\n\t\t// For event-only subgraphs, materialize only the event-bearing txs from\n\t\t// joined event context instead of every transaction in the range.\n\t\tconst txs = this.needsTransactions\n\t\t\t? txRows.map(reconstructTransaction)\n\t\t\t: synthesizeTxsFromEvents(eventLists.flat());\n\t\tfor (const t of txs) {\n\t\t\tmap.get(t.block_height)?.txs.push(t);\n\t\t}\n\t\tfor (const list of eventLists) {\n\t\t\tfor (const e of list) {\n\t\t\t\tconst bd = map.get(e.block_height);\n\t\t\t\tif (!bd) continue;\n\t\t\t\t// vm rows ride their own clock: never merged into `events`, whose\n\t\t\t\t// order is classic event_index.\n\t\t\t\tif (VM_INDEX_EVENT_TYPES.has(e.event_type)) {\n\t\t\t\t\tbd.vmEvents ??= [];\n\t\t\t\t\tbd.vmEvents.push(reconstructEvent(e));\n\t\t\t\t} else {\n\t\t\t\t\tbd.events.push(reconstructEvent(e));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Canonical ordering — multi-type event walks merge here, per clock.\n\t\tfor (const bd of map.values()) {\n\t\t\tbd.txs.sort((a, b) => a.tx_index - b.tx_index);\n\t\t\tbd.events.sort((a, b) => a.event_index - b.event_index);\n\t\t\tbd.vmEvents?.sort((a, b) => a.event_index - b.event_index);\n\t\t}\n\t\treturn map;\n\t}\n}\n\n/**\n * Wraps a primary source and falls back to a secondary, per call, when the\n * primary throws. Use it to make the HTTP (Streams+Index) plane a SOFT dependency:\n * if api is unavailable, the processor reads the Postgres tap and keeps advancing\n * instead of stalling. Safe to mix mid-stream — both taps read the same canonical\n * chain at the same heights and the cursor is forward-only. Stateless (no breaker)\n * so it's failover-safe across replicas; the primary is retried every call and\n * resumes transparently once healthy.\n */\nexport class FallbackBlockSource implements BlockSource {\n\t/** True when the LAST `getTip()` call actually used the primary — guards\n\t * `getDecodedHeights()` against returning the primary's stale cached\n\t * envelope from an earlier success after a call that just fell back. */\n\tprivate lastTipFromPrimary = false;\n\n\tconstructor(\n\t\tprivate readonly primary: BlockSource,\n\t\tprivate readonly fallback: BlockSource,\n\t) {}\n\n\tasync getTip(opts?: {\n\t\twait?: number;\n\t\tknownHeight?: number;\n\t}): Promise<number> {\n\t\ttry {\n\t\t\tconst tip = await this.primary.getTip(opts);\n\t\t\tthis.lastTipFromPrimary = true;\n\t\t\treturn tip;\n\t\t} catch (err) {\n\t\t\tlogger.warn(\"block source primary getTip failed — using DB tap\", {\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\tthis.lastTipFromPrimary = false;\n\t\t\t// The DB tap has no wait/long-poll notion — a plain read is already\n\t\t\t// instant, so `opts` is dropped here rather than forwarded.\n\t\t\treturn this.fallback.getTip();\n\t\t}\n\t}\n\n\t/** Only meaningful right after a `getTip()` that used the primary — the\n\t * fallback (Postgres tap) has no such notion, and returning the\n\t * primary's stale cache after a fallback would mismatch the returned\n\t * tip. Local mode's own `decoderBoundTip` never calls this. */\n\tgetDecodedHeights(): Record<string, number | null> | undefined {\n\t\tif (!this.lastTipFromPrimary) return undefined;\n\t\treturn this.primary.getDecodedHeights?.();\n\t}\n\n\tasync loadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\ttry {\n\t\t\treturn await this.primary.loadBlockRange(fromHeight, toHeight);\n\t\t} catch (err) {\n\t\t\tlogger.warn(\"block source primary loadBlockRange failed — using DB tap\", {\n\t\t\t\tfrom: fromHeight,\n\t\t\t\tto: toHeight,\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\treturn this.fallback.loadBlockRange(fromHeight, toHeight);\n\t\t}\n\t}\n\n\t/** Probe via the primary only; a probe failure just means \"no skip\" —\n\t * the caller falls back to plain batch advancement. */\n\tasync nextDataHeight(\n\t\tafterHeight: number,\n\t\tuntilHeight: number,\n\t): Promise<number | null> {\n\t\tif (!this.primary.nextDataHeight) return afterHeight + 1;\n\t\ttry {\n\t\t\treturn await this.primary.nextDataHeight(afterHeight, untilHeight);\n\t\t} catch {\n\t\t\treturn afterHeight + 1;\n\t\t}\n\t}\n}\n\nconst postgresBlockSource = new PostgresBlockSource();\n\n/**\n * HTTP (Streams+Index) chain source for a set of decoded event types, wrapped so\n * it falls back to the Postgres tap when api is down. Used by the chain-trigger\n * evaluator (which isn't subgraph-scoped, so it can't go through resolveBlockSource).\n *\n * `needsTransactions` defaults true (safe/unchanged) but the evaluator passes\n * `chainSubsNeedTransactions(chainSubs)` so a tick with no contract_call/deploy\n * trigger skips `walkTransactions` entirely — one fewer HTTP round trip and a\n * smaller `walkEvents` payload (no `tx_context` join) on every such tick.\n *\n * `httpClient` defaults to a fresh client (unchanged behavior) — pass one in\n * to reuse across calls. The evaluator's own long-lived loop does this so\n * `IndexHttpClient.waitIsSupported()` reflects what THIS\n * server actually supports instead of resetting every tick.\n */\nexport function buildChainBlockSource(\n\teventTypes: string[],\n\tneedsTransactions = true,\n\thttpClient: IndexHttpClient = buildHttpClient(),\n): BlockSource {\n\treturn new FallbackBlockSource(\n\t\tnew PublicApiBlockSource(\n\t\t\thttpClient,\n\t\t\teventTypes,\n\t\t\tundefined,\n\t\t\tneedsTransactions,\n\t\t),\n\t\tpostgresBlockSource,\n\t);\n}\n\nexport function buildHttpClient(): IndexHttpClient {\n\treturn createInternalIndexHttpClient();\n}\n\n/**\n * Resolve the block source for a subgraph. `SUBGRAPH_SOURCE=streams-index`\n * opts eligible subgraphs onto the public Streams clock + Index data;\n * `SUBGRAPH_SOURCE=observer-http` pages internal observer-events (experimental).\n * Default stays on the Postgres tap.\n */\nexport function resolveBlockSource(subgraph?: SubgraphDefinition): BlockSource {\n\tif (process.env.SUBGRAPH_SOURCE === \"observer-http\") {\n\t\tconst baseUrl = process.env.OBSERVER_HTTP_URL;\n\t\tif (!baseUrl) {\n\t\t\tthrow new Error(\n\t\t\t\t\"SUBGRAPH_SOURCE=observer-http requires OBSERVER_HTTP_URL\",\n\t\t\t);\n\t\t}\n\t\treturn new ObserverHttpBlockSource({\n\t\t\tbaseUrl,\n\t\t\ttoken: process.env.OBSERVER_HTTP_EXPORT_TOKEN || null,\n\t\t});\n\t}\n\tif (\n\t\tprocess.env.SUBGRAPH_SOURCE === \"streams-index\" &&\n\t\tsubgraph &&\n\t\tisStreamsIndexEligible(subgraph)\n\t) {\n\t\t// Soft-depend on api: fall back to the Postgres tap per-call if the HTTP\n\t\t// plane is down, so the processor keeps advancing instead of stalling.\n\t\treturn new FallbackBlockSource(\n\t\t\tnew PublicApiBlockSource(\n\t\t\t\tbuildHttpClient(),\n\t\t\t\treferencedIndexEventTypes(subgraph),\n\t\t\t\tcanSparseScan(subgraph) ? sparseProbeTargets(subgraph) : undefined,\n\t\t\t\tneedsTransactionData(subgraph),\n\t\t\t),\n\t\t\tpostgresBlockSource,\n\t\t);\n\t}\n\tif (process.env.SUBGRAPH_SOURCE === \"streams-index\" && subgraph) {\n\t\tlogger.debug(\"Subgraph not streams-index eligible, using DB tap\", {\n\t\t\tsubgraph: subgraph.name,\n\t\t});\n\t}\n\treturn postgresBlockSource;\n}\n",
8
8
  "import type {\n\tBlock,\n\tDatabase,\n\tEvent,\n\tTransaction,\n} from \"@secondlayer/shared/db\";\nimport type { Kysely } from \"kysely\";\n\n/** A runtime event row. `clock: \"vm\"` marks an opt-in `vm_events` row whose\n * `event_index` is `ordinal` — a second ordinal that never sorts or\n * dedupes against classic `events.event_index`. */\nexport type RuntimeEvent = Event & { clock?: \"vm\" };\n\nexport interface BlockData {\n\tblock: Block;\n\ttxs: Transaction[];\n\t/** Classic `events` rows (Streams 1.0 clock). */\n\tevents: Event[];\n\t/** Opt-in `vm_events` rows on their own clock. Absent/empty on `\"*\"`\n\t * nodes and on archives collected without `storage`/`contract_calls`. */\n\tvmEvents?: RuntimeEvent[];\n}\n\n/** Stable synthetic id for a vm row. Distinct from the classic `tx#index`\n * so a print at event_index N and a map_set at ordinal N never share\n * an identity. */\nexport function vmEventId(txId: string, vmEventIndex: number): string {\n\treturn `${txId}#vm:${vmEventIndex}`;\n}\n\n/**\n * Load a range of blocks with their transactions and events in 4 parallel queries.\n * Returns a Map keyed by block height. Non-canonical blocks are excluded.\n */\nexport async function loadBlockRange(\n\tdb: Kysely<Database>,\n\tfromHeight: number,\n\ttoHeight: number,\n): Promise<Map<number, BlockData>> {\n\tconst [blocks, txs, events, vmRows] = await Promise.all([\n\t\tdb\n\t\t\t.selectFrom(\"blocks\")\n\t\t\t.selectAll()\n\t\t\t.where(\"height\", \">=\", fromHeight)\n\t\t\t.where(\"height\", \"<=\", toHeight)\n\t\t\t.where(\"canonical\", \"=\", true)\n\t\t\t.execute(),\n\t\tdb\n\t\t\t.selectFrom(\"transactions\")\n\t\t\t.selectAll()\n\t\t\t.where(\"block_height\", \">=\", fromHeight)\n\t\t\t.where(\"block_height\", \"<=\", toHeight)\n\t\t\t.execute(),\n\t\tdb\n\t\t\t.selectFrom(\"events\")\n\t\t\t.selectAll()\n\t\t\t.where(\"block_height\", \">=\", fromHeight)\n\t\t\t.where(\"block_height\", \"<=\", toHeight)\n\t\t\t.execute(),\n\t\tdb\n\t\t\t.selectFrom(\"vm_events\")\n\t\t\t.selectAll()\n\t\t\t.where(\"block_height\", \">=\", fromHeight)\n\t\t\t.where(\"block_height\", \"<=\", toHeight)\n\t\t\t.orderBy(\"ordinal\", \"asc\")\n\t\t\t.execute(),\n\t]);\n\n\t// Index by block height (coerce to number — bigint columns may return as string or number)\n\tconst txsByHeight = new Map<number, Transaction[]>();\n\tfor (const tx of txs) {\n\t\tconst h = Number(tx.block_height);\n\t\tconst list = txsByHeight.get(h) ?? [];\n\t\tlist.push(tx);\n\t\ttxsByHeight.set(h, list);\n\t}\n\n\tconst eventsByHeight = new Map<number, Event[]>();\n\tfor (const evt of events) {\n\t\tconst h = Number(evt.block_height);\n\t\tconst list = eventsByHeight.get(h) ?? [];\n\t\tlist.push(evt);\n\t\teventsByHeight.set(h, list);\n\t}\n\n\tconst vmByHeight = new Map<number, RuntimeEvent[]>();\n\tfor (const row of vmRows) {\n\t\tconst h = Number(row.block_height);\n\t\tconst list = vmByHeight.get(h) ?? [];\n\t\tlist.push({\n\t\t\tid: vmEventId(row.tx_id, Number(row.ordinal)),\n\t\t\ttx_id: row.tx_id,\n\t\t\tblock_height: h,\n\t\t\tevent_index: Number(row.ordinal),\n\t\t\ttype: row.type,\n\t\t\tdata: row.data,\n\t\t\tcreated_at: row.created_at,\n\t\t\tclock: \"vm\",\n\t\t} as RuntimeEvent);\n\t\tvmByHeight.set(h, list);\n\t}\n\n\tconst result = new Map<number, BlockData>();\n\tfor (const block of blocks) {\n\t\tconst h = Number(block.height);\n\t\tresult.set(h, {\n\t\t\tblock,\n\t\t\ttxs: txsByHeight.get(h) ?? [],\n\t\t\tevents: eventsByHeight.get(h) ?? [],\n\t\t\tvmEvents: vmByHeight.get(h) ?? [],\n\t\t});\n\t}\n\n\treturn result;\n}\n\n/**\n * Compute average events per block from a loaded batch.\n * Used for adaptive batch sizing.\n */\nexport function avgEventsPerBlock(batch: Map<number, BlockData>): number {\n\tif (batch.size === 0) return 0;\n\tlet totalEvents = 0;\n\tfor (const data of batch.values()) {\n\t\ttotalEvents += data.events.length;\n\t}\n\treturn totalEvents / batch.size;\n}\n",
9
9
  "import {\n\tVM_NODE_TO_STORED_TYPE,\n\ttype VmNodeEventType,\n} from \"@secondlayer/shared\";\nimport type { Block, Event, Transaction } from \"@secondlayer/shared/db\";\nimport {\n\ttype BlockData,\n\ttype RuntimeEvent,\n\tvmEventId,\n} from \"./batch-loader.ts\";\nimport type { BlockSource } from \"./block-source.ts\";\n\n/** Duplicated from indexer — do not import `@secondlayer/indexer` (cycle). */\nconst OBSERVER_HTTP_EXPORT_PATH = \"/internal/observer-events\";\n\nexport type ObserverHttpFetch = (\n\tinput: string | URL | Request,\n\tinit?: RequestInit,\n) => Promise<Response>;\n\nexport type ObserverHttpBlockSourceOpts = {\n\t/** e.g. http://127.0.0.1:3700 — trailing slash stripped */\n\tbaseUrl: string;\n\ttoken?: string | null;\n\tfetch?: ObserverHttpFetch;\n\t/** Unused on the wire; list filtering is server-side. */\n\tnetwork?: string;\n};\n\ntype ObserverEventsNext = {\n\tafter_height: number;\n\tafter_index_block_hash: string;\n};\n\ntype SbaObserverMessage = {\n\tpath: string;\n\tpayload: unknown;\n\tcontent_sha256: string;\n\tblock_height: number | null;\n\tindex_block_hash: string | null;\n\treceived_at?: string;\n};\n\ntype ObserverEventsResponse = {\n\tevents: SbaObserverMessage[];\n\tnext: ObserverEventsNext | null;\n};\n\ntype ObserverTipResponse = {\n\tblock_height: number;\n\tindex_block_hash: string | null;\n};\n\ntype ObserverTx = {\n\ttxid?: string;\n\ttx_index?: number;\n\ttx_type?: string;\n\tsender_address?: string;\n\tstatus?: string;\n\traw_result?: string | null;\n\traw_tx?: string;\n\tcontract_call?: { contract_id?: string; function_name?: string };\n\tsmart_contract?: { contract_id?: string };\n};\n\ntype ObserverEvent = {\n\ttxid?: string;\n\tevent_index?: number;\n\ttype?: string;\n\t[key: string]: unknown;\n};\n\ntype ObserverNewBlockPayload = {\n\tblock_height?: number;\n\tblock_hash?: string;\n\tparent_block_hash?: string;\n\tburn_block_height?: number;\n\tburn_block_hash?: string | null;\n\tindex_block_hash?: string | null;\n\ttimestamp?: number;\n\tblock_time?: number;\n\tburn_block_time?: number;\n\tburn_block_timestamp?: number;\n\ttransactions?: ObserverTx[];\n\tevents?: ObserverEvent[];\n\t/** Opt-in traces; omitted on `\"*\"` bodies. */\n\tvm_events?: ObserverVmTrace[];\n};\n\ntype ObserverVmTrace = {\n\ttxid?: string;\n\tcommitted?: boolean;\n\ttype?: string;\n\t[key: string]: unknown;\n};\n\nfunction stripTrailingSlash(url: string): string {\n\treturn url.replace(/\\/+$/, \"\");\n}\n\nfunction blockTimestamp(payload: ObserverNewBlockPayload): number {\n\tfor (const value of [\n\t\tpayload.timestamp,\n\t\tpayload.block_time,\n\t\tpayload.burn_block_time,\n\t\tpayload.burn_block_timestamp,\n\t]) {\n\t\tif (typeof value === \"number\" && Number.isFinite(value) && value > 0) {\n\t\t\treturn value;\n\t\t}\n\t}\n\treturn 0;\n}\n\nfunction eventData(event: ObserverEvent): unknown {\n\tif (typeof event.type === \"string\" && event.type in event) {\n\t\treturn event[event.type] ?? {};\n\t}\n\treturn {};\n}\n\n/** Map a `/new_block` observer payload into subgraph `BlockData`. No Hiro. */\nexport function mapNewBlockPayloadToBlockData(payload: unknown): BlockData {\n\tconst p = payload as ObserverNewBlockPayload;\n\tif (\n\t\ttypeof p.block_height !== \"number\" ||\n\t\t!Number.isFinite(p.block_height) ||\n\t\ttypeof p.index_block_hash !== \"string\" ||\n\t\tp.index_block_hash.length === 0\n\t) {\n\t\tthrow new Error(\n\t\t\t\"observer /new_block payload missing block_height or index_block_hash\",\n\t\t);\n\t}\n\n\tconst height = p.block_height;\n\tconst block = {\n\t\theight,\n\t\thash: p.block_hash ?? \"\",\n\t\tparent_hash: p.parent_block_hash ?? \"\",\n\t\tburn_block_height: p.burn_block_height ?? 0,\n\t\tburn_block_hash: p.burn_block_hash ?? null,\n\t\tindex_block_hash: p.index_block_hash,\n\t\ttimestamp: blockTimestamp(p),\n\t\tcanonical: true,\n\t\tcreated_at: new Date(0),\n\t} as Block;\n\n\tconst txs: Transaction[] = (p.transactions ?? []).map((tx) => {\n\t\tlet contractId: string | null = null;\n\t\tlet functionName: string | null = null;\n\t\tif (tx.tx_type === \"contract_call\" && tx.contract_call) {\n\t\t\tcontractId = tx.contract_call.contract_id ?? null;\n\t\t\tfunctionName = tx.contract_call.function_name ?? null;\n\t\t} else if (tx.tx_type === \"smart_contract\" && tx.smart_contract) {\n\t\t\tcontractId = tx.smart_contract.contract_id ?? null;\n\t\t}\n\t\treturn {\n\t\t\ttx_id: tx.txid ?? \"\",\n\t\t\tblock_height: height,\n\t\t\ttx_index: tx.tx_index ?? 0,\n\t\t\ttype: tx.tx_type ?? \"\",\n\t\t\tsender: tx.sender_address ?? \"\",\n\t\t\tstatus: tx.status ?? \"success\",\n\t\t\tcontract_id: contractId,\n\t\t\tfunction_name: functionName,\n\t\t\tfunction_args: [],\n\t\t\traw_result: tx.raw_result ?? null,\n\t\t\traw_tx: tx.raw_tx ?? \"\",\n\t\t\tcreated_at: new Date(0),\n\t\t} as Transaction;\n\t});\n\n\tconst events: Event[] = (p.events ?? []).map((event) => {\n\t\tconst txId = event.txid ?? \"\";\n\t\tconst eventIndex = event.event_index ?? 0;\n\t\treturn {\n\t\t\tid: `${txId}#${eventIndex}`,\n\t\t\ttx_id: txId,\n\t\t\tblock_height: height,\n\t\t\tevent_index: eventIndex,\n\t\t\ttype: event.type ?? \"\",\n\t\t\tdata: eventData(event),\n\t\t\tcreated_at: new Date(0),\n\t\t} as Event;\n\t});\n\n\t// Opt-in `vm_events` (present only when the observer subscribed to\n\t// `storage` / `contract_calls`). Node type → stored name; own clock.\n\tconst vmEvents: RuntimeEvent[] = [];\n\tfor (const [i, trace] of (p.vm_events ?? []).entries()) {\n\t\tconst nodeType = trace.type;\n\t\tif (typeof nodeType !== \"string\" || !(nodeType in VM_NODE_TO_STORED_TYPE))\n\t\t\tcontinue;\n\t\tconst stored = VM_NODE_TO_STORED_TYPE[nodeType as VmNodeEventType];\n\t\tconst body = trace[nodeType];\n\t\tif (!body || typeof body !== \"object\") continue;\n\t\tconst txId = trace.txid ?? \"\";\n\t\tconst vmIndex = i;\n\t\tvmEvents.push({\n\t\t\tid: vmEventId(txId, vmIndex),\n\t\t\ttx_id: txId,\n\t\t\tblock_height: height,\n\t\t\tevent_index: vmIndex,\n\t\t\ttype: stored,\n\t\t\tdata: body,\n\t\t\tcreated_at: new Date(0),\n\t\t\tclock: \"vm\",\n\t\t} as RuntimeEvent);\n\t}\n\n\treturn { block, txs, events, vmEvents };\n}\n\n/**\n * Experimental BlockSource: pages indexer `/internal/observer-events` and maps\n * raw `/new_block` bodies to `BlockData`. Not the production default.\n */\nexport class ObserverHttpBlockSource implements BlockSource {\n\tprivate readonly baseUrl: string;\n\tprivate readonly token: string | null;\n\tprivate readonly fetchFn: ObserverHttpFetch;\n\n\tconstructor(opts: ObserverHttpBlockSourceOpts) {\n\t\tthis.baseUrl = stripTrailingSlash(opts.baseUrl);\n\t\tthis.token = opts.token ?? null;\n\t\tthis.fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis);\n\t}\n\n\tprivate headers(): Record<string, string> {\n\t\tif (!this.token) return {};\n\t\treturn { Authorization: `Bearer ${this.token}` };\n\t}\n\n\tasync getTip(): Promise<number> {\n\t\tconst res = await this.fetchFn(\n\t\t\t`${this.baseUrl}${OBSERVER_HTTP_EXPORT_PATH}/tip`,\n\t\t\t{ headers: this.headers() },\n\t\t);\n\t\tif (!res.ok) {\n\t\t\tthrow new Error(\n\t\t\t\t`observer tip HTTP ${res.status}: ${await res.text().catch(() => \"\")}`,\n\t\t\t);\n\t\t}\n\t\tlet body: ObserverTipResponse;\n\t\ttry {\n\t\t\tbody = (await res.json()) as ObserverTipResponse;\n\t\t} catch (err) {\n\t\t\tthrow new Error(\n\t\t\t\t`observer tip invalid JSON: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t);\n\t\t}\n\t\tif (\n\t\t\ttypeof body.block_height !== \"number\" ||\n\t\t\t!Number.isFinite(body.block_height)\n\t\t) {\n\t\t\tthrow new Error(\"observer tip missing block_height\");\n\t\t}\n\t\treturn body.block_height;\n\t}\n\n\tasync loadBlockRange(\n\t\tfromHeight: number,\n\t\ttoHeight: number,\n\t): Promise<Map<number, BlockData>> {\n\t\tconst result = new Map<number, BlockData>();\n\t\tlet afterHeight: number | undefined =\n\t\t\tfromHeight > 0 ? fromHeight - 1 : undefined;\n\t\tlet afterIndexBlockHash: string | undefined;\n\n\t\tfor (;;) {\n\t\t\tconst url = new URL(`${this.baseUrl}${OBSERVER_HTTP_EXPORT_PATH}`);\n\t\t\turl.searchParams.set(\"path\", \"/new_block\");\n\t\t\turl.searchParams.set(\"limit\", \"100\");\n\t\t\tif (afterHeight != null) {\n\t\t\t\turl.searchParams.set(\"after_height\", String(afterHeight));\n\t\t\t}\n\t\t\tif (afterIndexBlockHash) {\n\t\t\t\turl.searchParams.set(\"after_index_block_hash\", afterIndexBlockHash);\n\t\t\t}\n\n\t\t\tconst res = await this.fetchFn(url.toString(), {\n\t\t\t\theaders: this.headers(),\n\t\t\t});\n\t\t\tif (!res.ok) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`observer events HTTP ${res.status}: ${await res.text().catch(() => \"\")}`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tlet body: ObserverEventsResponse;\n\t\t\ttry {\n\t\t\t\tbody = (await res.json()) as ObserverEventsResponse;\n\t\t\t} catch (err) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`observer events invalid JSON: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tlet pastTo = false;\n\t\t\tfor (const message of body.events ?? []) {\n\t\t\t\tif (message.path === \"/new_burn_block\") continue;\n\t\t\t\tconst height = (message.payload as ObserverNewBlockPayload)\n\t\t\t\t\t?.block_height;\n\t\t\t\tif (typeof height === \"number\" && height > toHeight) {\n\t\t\t\t\tpastTo = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tconst data = mapNewBlockPayloadToBlockData(message.payload);\n\t\t\t\tconst h = data.block.height;\n\t\t\t\tif (h >= fromHeight && h <= toHeight) {\n\t\t\t\t\tresult.set(h, data);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (pastTo || body.next == null) break;\n\t\t\tafterHeight = body.next.after_height;\n\t\t\tafterIndexBlockHash = body.next.after_index_block_hash;\n\t\t}\n\n\t\treturn result;\n\t}\n}\n",
10
10
  "import type { Block, Event, Transaction } from \"@secondlayer/shared/db\";\nimport type {\n\tIndexBlockRow,\n\tIndexEventRow,\n\tIndexTransactionRow,\n} from \"@secondlayer/shared/index-http\";\nimport { vmEventId } from \"./batch-loader.ts\";\n\nexport type {\n\tIndexBlockRow,\n\tIndexEventRow,\n\tIndexTransactionRow,\n} from \"@secondlayer/shared/index-http\";\n\n/**\n * Reconstruct the raw `blocks`/`events` row shapes the subgraph runtime expects\n * (the shapes `matchSources` + the handler runner read) from the decoded,\n * flattened rows the public Index API serves. This is what lets\n * `PublicApiBlockSource` feed the EXISTING pipeline byte-identically to the\n * Postgres tap.\n *\n * Two shape gaps the Index API can't avoid, handled here:\n * - Index event rows are flat (`event_type: \"ft_transfer\"`, columns\n * `sender`/`amount`/…); the runtime expects the node's nested `data` keyed by\n * a suffixed `type` (`ft_transfer_event`), with print under `contract_event`.\n * - nft/print Clarity values: the runtime decodes from the canonical hex\n * (`raw_value`), so we place the Index hex there. (The node's verbose\n * serde-tagged `value`, e.g. `{UInt:223}`, is not reproducible from hex and\n * is no longer read — see the nft tokenId normalization in runner.ts.)\n */\n\n/** ISO `block_time` (or null) → unix-seconds integer the runtime expects. */\nfunction isoToUnixSeconds(iso: string | null): number {\n\tif (!iso) return 0;\n\treturn Math.floor(new Date(iso).getTime() / 1000);\n}\n\nexport function reconstructBlock(b: IndexBlockRow): Block {\n\treturn {\n\t\theight: b.block_height,\n\t\thash: b.block_hash,\n\t\tparent_hash: b.parent_hash,\n\t\tburn_block_height: b.burn_block_height,\n\t\tburn_block_hash: b.burn_block_hash,\n\t\t// Present since the blocks endpoint started serving it; older API\n\t\t// deployments omit it, and a null propagates to a clean refusal in\n\t\t// `ctx.client.readOnly` rather than an unpinned tip read.\n\t\tindex_block_hash: b.index_block_hash ?? null,\n\t\ttimestamp: isoToUnixSeconds(b.block_time),\n\t\t// Index serves canonical rows only.\n\t\tcanonical: true,\n\t\tcreated_at: new Date(0),\n\t} as Block;\n}\n\nexport function reconstructTransaction(t: IndexTransactionRow): Transaction {\n\treturn {\n\t\ttx_id: t.tx_id,\n\t\tblock_height: t.block_height,\n\t\ttx_index: t.tx_index,\n\t\ttype: t.tx_type,\n\t\tsender: t.sender,\n\t\tstatus: t.status,\n\t\tcontract_id:\n\t\t\tt.contract_call?.contract_id ?? t.smart_contract?.contract_id ?? null,\n\t\tfunction_name: t.contract_call?.function_name ?? null,\n\t\t// Raw hex ClarityValues — the runner decodes them via its own deserializeCV\n\t\t// path, identical to the DB tap.\n\t\tfunction_args: t.contract_call?.function_args_hex ?? [],\n\t\traw_result: t.contract_call?.result_hex ?? null,\n\t\traw_tx: \"\",\n\t\tcreated_at: new Date(0),\n\t} as Transaction;\n}\n\n/**\n * Build a minimal canonical Transaction from an event row's joined tx context\n * (`tx_context=true` on /v1/index/events). For event-only subgraphs this lets\n * the loader materialize only the txs that actually carry a matched event,\n * instead of draining every transaction in the range (the ~37x reindex\n * over-fetch — see docs/sprints/indexing-speed/plan.md). `function_args` /\n * `raw_result` are empty: an event source never reads them (contract_call-only).\n */\nexport function reconstructTxFromEventRow(e: IndexEventRow): Transaction {\n\treturn {\n\t\ttx_id: e.tx_id,\n\t\tblock_height: e.block_height,\n\t\ttx_index: e.tx_index ?? 0,\n\t\ttype: e.tx_type ?? \"\",\n\t\tsender: e.tx_sender ?? \"\",\n\t\tstatus: e.tx_status ?? \"success\",\n\t\tcontract_id: e.tx_contract_id ?? null,\n\t\tfunction_name: e.tx_function_name ?? null,\n\t\tfunction_args: [],\n\t\traw_result: null,\n\t\traw_tx: \"\",\n\t\tcreated_at: new Date(0),\n\t} as Transaction;\n}\n\nexport function reconstructEvent(e: IndexEventRow): Event {\n\tconst base = {\n\t\t// Synthetic, deterministic id. The runtime only reads `id` for error logs\n\t\t// and the `*`-wildcard payload (which is streams-index-ineligible).\n\t\tid: `${e.tx_id}#${e.event_index}`,\n\t\ttx_id: e.tx_id,\n\t\tblock_height: e.block_height,\n\t\tevent_index: e.event_index,\n\t\tcreated_at: new Date(0),\n\t};\n\n\tswitch (e.event_type) {\n\t\tcase \"ft_transfer\":\n\t\tcase \"ft_mint\":\n\t\tcase \"ft_burn\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\ttype: `${e.event_type}_event`,\n\t\t\t\tdata: {\n\t\t\t\t\tasset_identifier: e.asset_identifier,\n\t\t\t\t\tsender: e.sender,\n\t\t\t\t\trecipient: e.recipient,\n\t\t\t\t\tamount: e.amount,\n\t\t\t\t},\n\t\t\t} as Event;\n\n\t\tcase \"nft_transfer\":\n\t\tcase \"nft_mint\":\n\t\tcase \"nft_burn\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\ttype: `${e.event_type}_event`,\n\t\t\t\tdata: {\n\t\t\t\t\tasset_identifier: e.asset_identifier,\n\t\t\t\t\tsender: e.sender,\n\t\t\t\t\trecipient: e.recipient,\n\t\t\t\t\t// Canonical hex → runner decodes tokenId from raw_value.\n\t\t\t\t\traw_value: e.value,\n\t\t\t\t},\n\t\t\t} as Event;\n\n\t\tcase \"stx_transfer\":\n\t\tcase \"stx_mint\":\n\t\tcase \"stx_burn\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\ttype: `${e.event_type}_event`,\n\t\t\t\tdata: {\n\t\t\t\t\tsender: e.sender,\n\t\t\t\t\trecipient: e.recipient,\n\t\t\t\t\tamount: e.amount,\n\t\t\t\t\t// Raw stx_transfer always carries memo (\"\" when empty); Index\n\t\t\t\t\t// returns null for empty — default to \"\" to match the DB tap.\n\t\t\t\t\t...(e.event_type === \"stx_transfer\" ? { memo: e.memo ?? \"\" } : {}),\n\t\t\t\t},\n\t\t\t} as Event;\n\n\t\tcase \"stx_lock\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\ttype: \"stx_lock_event\",\n\t\t\t\tdata: {\n\t\t\t\t\tlocked_address: e.sender,\n\t\t\t\t\tlocked_amount: e.amount,\n\t\t\t\t\tunlock_height: e.payload.unlock_height,\n\t\t\t\t},\n\t\t\t} as Event;\n\n\t\tcase \"print\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\ttype: \"contract_event\",\n\t\t\t\tdata: {\n\t\t\t\t\ttopic: e.payload.topic,\n\t\t\t\t\t// Index serves `contract_id`. Matcher + runner also accept\n\t\t\t\t\t// legacy `contract_identifier`; emit the current field.\n\t\t\t\t\tcontract_id: e.contract_id,\n\t\t\t\t\tvalue: e.payload.value,\n\t\t\t\t\traw_value: e.payload.raw_value,\n\t\t\t\t},\n\t\t\t} as Event;\n\n\t\tcase \"nested_contract_call\":\n\t\tcase \"var_set\":\n\t\tcase \"map_set\":\n\t\tcase \"map_insert\":\n\t\tcase \"map_delete\":\n\t\t\t// Second clock: `event_index` here is ordinal. Distinct id +\n\t\t\t// `clock` so it never dedupes or sorts against a classic row.\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tid: vmEventId(e.tx_id, e.event_index),\n\t\t\t\tclock: \"vm\",\n\t\t\t\ttype: e.event_type,\n\t\t\t\tdata: {\n\t\t\t\t\tcontract_identifier: e.contract_id,\n\t\t\t\t\t...(\"payload\" in e && e.payload && typeof e.payload === \"object\"\n\t\t\t\t\t\t? (e.payload as Record<string, unknown>)\n\t\t\t\t\t\t: {}),\n\t\t\t\t\tsender: \"sender\" in e ? e.sender : undefined,\n\t\t\t\t\tcaller: \"caller\" in e ? e.caller : undefined,\n\t\t\t\t\tfunction_name: \"function_name\" in e ? e.function_name : undefined,\n\t\t\t\t\tfunction_args: \"function_args\" in e ? e.function_args : undefined,\n\t\t\t\t\traw_result: \"raw_result\" in e ? e.raw_result : undefined,\n\t\t\t\t\tmap_name: \"map\" in e ? e.map : undefined,\n\t\t\t\t\tvar_name: \"var_name\" in e ? e.var_name : undefined,\n\t\t\t\t\traw_key: \"raw_key\" in e ? e.raw_key : undefined,\n\t\t\t\t\traw_value: \"raw_value\" in e ? e.raw_value : undefined,\n\t\t\t\t},\n\t\t\t} as Event;\n\t}\n}\n",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secondlayer/subgraphs",
3
- "version": "6.2.4",
3
+ "version": "6.2.6",
4
4
  "description": "Describe the Stacks data you want as one TypeScript file; it fills itself and survives reorgs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,7 +51,7 @@
51
51
  "prepublishOnly": "bun run build"
52
52
  },
53
53
  "dependencies": {
54
- "@secondlayer/shared": "^11.12.2",
54
+ "@secondlayer/shared": "^11.12.5",
55
55
  "@secondlayer/stacks": "^6.1.0",
56
56
  "esbuild": "^0.28.2",
57
57
  "kysely": "0.28.17",