diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e280fd6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.github +.turbo +**/node_modules +coverage +dist +*.log +.DS_Store diff --git a/.env.example b/.env.example index 072d2f0..374fd43 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ START_LEDGER= # Contracts # Keep real production values out of commits unless they are intended to be # public configuration. -FLOW_CONTRACT_ID= -LOCKUP_CONTRACT_ID= -STREAM_NFT_CONTRACT_ID= -ROUTER_CONTRACT_ID= +FLOW_CONTRACT_ID=CB7W5RFUINUCDAIR2AFY7EIZ66EYIPVKD4CQCQGUGY6GKTYAGYCA2M7C +LOCKUP_CONTRACT_ID=CBQC5JBGUUOU74BIT67QOAEDIEUDFN3KIVNKIOHAJ5XBXX6HFG7M5FMB +STREAM_NFT_CONTRACT_ID=CA6YXNZ2IDFZVO4VXINT2E4R7UNAMYM6INH333VLVAK6P3HY5O3T4CRT +ROUTER_CONTRACT_ID=CDQRBMV73XXTDBIEMMA7U4ZSQ42DYSKLE7HE4QJIOMFJA5ZEYMLCJYKK diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c81cc56 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + push: + branches: + - staging + - main + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.8 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Type check + run: bun run type-check + + - name: Test + run: bun run test diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..89daccd --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,29 @@ +name: Deploy Staging + +on: + push: + branches: + - staging + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Load SSH key + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.STAGING_VPS_SSH_KEY }} + + - name: Add VPS host key + run: | + mkdir -p ~/.ssh + ssh-keyscan -p "${{ secrets.STAGING_VPS_PORT || '22' }}" "${{ secrets.STAGING_VPS_HOST }}" >> ~/.ssh/known_hosts + + - name: Deploy + run: | + ssh -p "${{ secrets.STAGING_VPS_PORT || '22' }}" "${{ secrets.STAGING_VPS_USER }}@${{ secrets.STAGING_VPS_HOST }}" \ + "cd \"${{ secrets.STAGING_DEPLOY_PATH || '/opt/fundable-indexer-staging' }}\" && APP_ROOT=\"${{ secrets.STAGING_DEPLOY_PATH || '/opt/fundable-indexer-staging' }}\" DEPLOY_ENV=staging DEPLOY_BRANCH=staging ENV_FILE=deploy/.env.staging ./deploy/deploy.sh" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..49e0ad7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,29 @@ +name: Deploy + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Load SSH key + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PROD_VPS_SSH_KEY }} + + - name: Add VPS host key + run: | + mkdir -p ~/.ssh + ssh-keyscan -p "${{ secrets.PROD_VPS_PORT || '22' }}" "${{ secrets.PROD_VPS_HOST }}" >> ~/.ssh/known_hosts + + - name: Deploy + run: | + ssh -p "${{ secrets.PROD_VPS_PORT || '22' }}" "${{ secrets.PROD_VPS_USER }}@${{ secrets.PROD_VPS_HOST }}" \ + "cd \"${{ secrets.PROD_DEPLOY_PATH || '/opt/fundable-indexer' }}\" && APP_ROOT=\"${{ secrets.PROD_DEPLOY_PATH || '/opt/fundable-indexer' }}\" DEPLOY_ENV=production DEPLOY_BRANCH=main ENV_FILE=deploy/.env.production ./deploy/deploy.sh" diff --git a/.gitignore b/.gitignore index 53a34ca..4b1cb33 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,9 @@ coverage .turbo *.tsbuildinfo .env +deploy/.env +deploy/.env.staging +deploy/.env.production +!deploy/.env.example indexer-spec.md docs/ diff --git a/README.md b/README.md index 0df1fa0..da9a4d0 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,20 @@ The API starts at `v1`. The queries exposed under `v1` are `stream(id)`, `streams`, `flowDeposits`, `flowWithdrawals`, `flowAdjustments`, `flowEvents`, `lockupEvents`, and `nftTransfers`. +The `stream` read model now includes the canonical snapshot fields: + +- `streamType` +- `startTime` +- `endTime` +- `startTimeReadable` +- `endTimeReadable` +- `status` +- `remainingAmount` +- `snapshotTime` +- `snapshotTimeReadable` +- `payloadRaw` +- `payloadReadable` + ### `distributions` `distributions` is reserved for the later distribution contract indexer. It stays separate so the stream code does not become a catch-all. @@ -114,6 +128,16 @@ The queries exposed under `v1` are `stream(id)`, `streams`, `flowDeposits`, `flo - `transfers/` - Stream NFT transfer persistence. - `api/` - GraphQL/Apollo read layer. +## CI + +Pull requests run the repository checks in GitHub Actions: + +- `bun run lint` +- `bun run type-check` +- `bun run test` + +Deployment details are intentionally kept out of this public README. + ## Current Implemented Data Paths ### `flow_created` @@ -135,6 +159,9 @@ Stored data: - token - rate per second - snapshot time +- canonical stream snapshot fields + +The stream row is the shared read model for both Flow and Lockup creation now. It carries the current snapshot state alongside the raw and readable payload forms. ### `flow_deposit` @@ -196,6 +223,7 @@ Stored data: - old rate - new rate - payload JSON +- payload readable JSON - ledger number - transaction hash - event index @@ -209,6 +237,7 @@ They use the same pattern: - event name - stream id - payload stored as structured JSON text +- readable payload JSON - ledger / transaction / event identity ### `lockup_created`, `lockup_withdraw`, `lockup_canceled`, `lockup_renounced` @@ -217,12 +246,43 @@ These are stored as Lockup lifecycle events. They follow the same model as Flow lifecycle events. +The lockup creation event also writes the canonical `streams` row, including: + +- `streamType` +- `startTime` +- `endTime` +- `startTimeReadable` +- `endTimeReadable` +- `status` +- `remainingAmount` +- `snapshotTime` +- `snapshotTimeReadable` +- `payloadRaw` +- `payloadReadable` + ### `transfer` This is the Stream NFT transfer event. It records ownership movement for the stream NFT that represents the stream recipient rights. +## Canonical Stream Snapshot + +The `streams` table is now the canonical snapshot for both Flow and Lockup creation events. + +It stores: + +- the stream identity +- the contract that created it +- the sender, recipient, and token +- the stream type +- the current status +- the current remaining amount +- the chain timestamps as raw Unix values and readable ISO strings +- the raw contract payload and a readable JSON payload + +That makes the stream row fast to read for the frontend without losing the original event data. + ## How The API Layer Fits In The `api` package does not invent new data. @@ -239,6 +299,8 @@ Today it exposes: - `v1.lockupEvents(streamId: ...)` - returns Lockup lifecycle history - `v1.nftTransfers(contractId: ..., tokenId: ...)` - returns NFT transfer history +The `Stream` GraphQL type includes the readable time fields as well as the raw timestamps. + The pattern is always the same: 1. Add a GraphQL field. diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..2911454 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,11 @@ +FROM oven/bun:1.3.8 + +WORKDIR /app + +COPY . . + +RUN bun install --frozen-lockfile + +WORKDIR /app/api + +CMD ["bun", "run", "start"] diff --git a/api/package.json b/api/package.json index 14313a7..b3a2cc3 100644 --- a/api/package.json +++ b/api/package.json @@ -11,6 +11,7 @@ "codegen": "bun --print \"'api: no codegen configured yet'\"", "dev": "bun --watch src/main.ts", "lint": "biome check .", + "start": "bun src/main.ts", "test": "vitest run src", "type-check": "tsc -p tsconfig.json --noEmit" }, diff --git a/api/src/main.ts b/api/src/main.ts index de30e35..cbe4cd8 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -1,10 +1,12 @@ +import { fileURLToPath } from "node:url"; import { startStandaloneServer } from "@apollo/server/standalone"; -import { createDatabasePool, loadConfig } from "@fundable-indexer/common"; + +import { createDatabasePool, loadConfig, loadRuntimeEnv } from "@fundable-indexer/common"; import { createStreamGraphqlServer } from "./server.js"; async function main(): Promise { - const config = loadConfig(); + const config = loadConfig(loadRuntimeEnv(fileURLToPath(new URL(".", import.meta.url)))); const pool = createDatabasePool(config); const server = createStreamGraphqlServer({ sqlClient: pool }); diff --git a/api/src/resolvers/flows/adjustments.ts b/api/src/resolvers/flows/adjustments.ts index c032c1e..af95b3b 100644 --- a/api/src/resolvers/flows/adjustments.ts +++ b/api/src/resolvers/flows/adjustments.ts @@ -34,7 +34,7 @@ async function findFlowAdjustments( options: FlowAdjustmentListQueryOptions, ): Promise { const parts = [ - "select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index, created_at from flow_adjustments", + "select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from flow_adjustments", ]; const values: unknown[] = [options.streamId]; @@ -62,7 +62,7 @@ async function findFlowAdjustmentsConnection( ): Promise { return queryCursorRows( client, - "select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index, created_at from flow_adjustments", + "select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from flow_adjustments", options, ["stream_id = ?"], [options.streamId], @@ -78,6 +78,7 @@ function mapFlowAdjustmentRow(row: FlowAdjustmentRow) { oldRate: row.old_rate, newRate: row.new_rate, payload: row.payload, + payloadReadable: row.payload_readable, ledgerNumber: row.ledger_number.toString(), transactionHash: row.transaction_hash, eventIndex: row.event_index, diff --git a/api/src/resolvers/flows/lifecycle.ts b/api/src/resolvers/flows/lifecycle.ts index e9067c4..1fa2f46 100644 --- a/api/src/resolvers/flows/lifecycle.ts +++ b/api/src/resolvers/flows/lifecycle.ts @@ -34,7 +34,7 @@ async function findFlowEvents( options: ActivityListQueryOptions, ): Promise { const parts = [ - "select id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index, created_at from flow_events", + "select id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from flow_events", ]; const values: unknown[] = [options.streamId]; @@ -62,7 +62,7 @@ async function findFlowEventsConnection( ): Promise { return queryCursorRows( client, - "select id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index, created_at from flow_events", + "select id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from flow_events", options, ["stream_id = ?"], [options.streamId], @@ -76,6 +76,7 @@ function mapFlowEventRow(row: FlowEventRow) { streamId: row.stream_id.toString(), eventName: row.event_name, payload: row.payload, + payloadReadable: row.payload_readable, ledgerNumber: row.ledger_number.toString(), transactionHash: row.transaction_hash, eventIndex: row.event_index, diff --git a/api/src/resolvers/jsonScalar.ts b/api/src/resolvers/jsonScalar.ts new file mode 100644 index 0000000..900e3c0 --- /dev/null +++ b/api/src/resolvers/jsonScalar.ts @@ -0,0 +1,37 @@ +import { GraphQLError, GraphQLScalarType, Kind, type ValueNode } from "graphql"; + +export const jsonScalar = new GraphQLScalarType({ + name: "JSON", + description: "Arbitrary JSON value", + serialize(value) { + return value; + }, + parseValue(value) { + return value; + }, + parseLiteral(ast) { + return parseJsonLiteral(ast); + }, +}); + +function parseJsonLiteral(ast: ValueNode): unknown { + switch (ast.kind) { + case Kind.STRING: + case Kind.BOOLEAN: + return ast.value; + case Kind.INT: + case Kind.FLOAT: + return Number(ast.value); + case Kind.NULL: + return null; + case Kind.LIST: + return ast.values.map(parseJsonLiteral); + case Kind.OBJECT: + return Object.fromEntries( + ast.fields.map((field) => [field.name.value, parseJsonLiteral(field.value)]), + ); + case Kind.ENUM: + case Kind.VARIABLE: + throw new GraphQLError(`JSON literal cannot represent ${ast.kind}`); + } +} diff --git a/api/src/resolvers/lockups/index.ts b/api/src/resolvers/lockups/index.ts index b1ebd0d..649a23d 100644 --- a/api/src/resolvers/lockups/index.ts +++ b/api/src/resolvers/lockups/index.ts @@ -25,7 +25,7 @@ async function findLockupEvents( options: ActivityListQueryOptions, ): Promise { const parts = [ - "select id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index, created_at from lockup_events", + "select id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from lockup_events", ]; const values: unknown[] = [options.streamId]; @@ -53,7 +53,7 @@ async function findLockupEventsConnection( ): Promise { return queryCursorRows( client, - "select id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index, created_at from lockup_events", + "select id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from lockup_events", options, ["stream_id = ?"], [options.streamId], @@ -67,6 +67,7 @@ function mapLockupEventRow(row: LockupEventRow) { streamId: row.stream_id.toString(), eventName: row.event_name, payload: row.payload, + payloadReadable: row.payload_readable, ledgerNumber: row.ledger_number.toString(), transactionHash: row.transaction_hash, eventIndex: row.event_index, diff --git a/api/src/resolvers/streams/index.ts b/api/src/resolvers/streams/index.ts index f6a6f27..94f8bc7 100644 --- a/api/src/resolvers/streams/index.ts +++ b/api/src/resolvers/streams/index.ts @@ -7,6 +7,9 @@ import type { import { buildConnection, normalizeFirst, queryCursorRows } from "../pagination.js"; +const streamColumns = + "id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable, created_at, updated_at"; + export function createStreamV1Resolvers(client: ApiSqlClient) { return { async stream(_: unknown, args: { id: string }) { @@ -26,7 +29,7 @@ export function createStreamV1Resolvers(client: ApiSqlClient) { async function findStreamById(client: ApiSqlClient, id: string): Promise { const result = await client.query( - "select id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at from streams where id = $1 limit 1", + `select ${streamColumns} from streams where id = $1 limit 1`, [id], ); @@ -37,9 +40,7 @@ async function findStreams( client: ApiSqlClient, options: StreamListQueryOptions, ): Promise { - const parts = [ - "select id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at from streams", - ]; + const parts = [`select ${streamColumns} from streams`]; const values: unknown[] = []; if (options.contractId !== undefined) { @@ -70,7 +71,7 @@ async function findStreamsConnection( ): Promise { return queryCursorRows( client, - "select id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at from streams", + `select ${streamColumns} from streams`, options, options.contractId === undefined ? [] : ["contract_id = ?"], options.contractId === undefined ? [] : [options.contractId], @@ -84,8 +85,26 @@ function mapStreamRow(row: StreamRow) { sender: row.sender, recipient: row.recipient, contractId: row.contract_id, + streamType: row.stream_type, ratePerSecond: row.rate_per_second, streamId: row.stream_id.toString(), + startTime: row.start_time === null ? null : row.start_time.toString(), + endTime: row.end_time === null ? null : row.end_time.toString(), snapshotTime: row.snapshot_time.toString(), + startTimeReadable: row.start_time_readable, + endTimeReadable: row.end_time_readable, + status: row.status, + remainingAmount: row.remaining_amount, + snapshotTimeReadable: row.snapshot_time_readable, + payloadRaw: parsePayloadJson(row.payload_raw, "payload_raw"), + payloadReadable: parsePayloadJson(row.payload_readable, "payload_readable"), }; } + +function parsePayloadJson(value: string, columnName: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch (error) { + throw new Error(`Invalid stream ${columnName} JSON`, { cause: error }); + } +} diff --git a/api/src/schema.ts b/api/src/schema.ts index 3b1189d..54f804e 100644 --- a/api/src/schema.ts +++ b/api/src/schema.ts @@ -1,4 +1,6 @@ export const typeDefs = `#graphql + scalar JSON + type V1Query { stream(id: ID!): Stream streams(contractId: String, limit: Int, offset: Int): [Stream!]! @@ -21,11 +23,21 @@ export const typeDefs = `#graphql id: ID! contractId: String! streamId: String! + streamType: String! sender: String! recipient: String! token: String! ratePerSecond: String! + startTime: String + endTime: String snapshotTime: String! + startTimeReadable: String + endTimeReadable: String + status: String! + remainingAmount: String! + snapshotTimeReadable: String! + payloadRaw: JSON! + payloadReadable: JSON! } type FlowDeposit { @@ -57,6 +69,7 @@ export const typeDefs = `#graphql streamId: String! eventName: String! payload: String! + payloadReadable: String! ledgerNumber: String! transactionHash: String! eventIndex: Int! @@ -70,6 +83,7 @@ export const typeDefs = `#graphql oldRate: String! newRate: String! payload: String! + payloadReadable: String! ledgerNumber: String! transactionHash: String! eventIndex: Int! @@ -81,6 +95,7 @@ export const typeDefs = `#graphql streamId: String! eventName: String! payload: String! + payloadReadable: String! ledgerNumber: String! transactionHash: String! eventIndex: Int! diff --git a/api/src/server.ts b/api/src/server.ts index b9284ed..5d762b4 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -1,6 +1,7 @@ import { ApolloServer } from "@apollo/server"; import { createV1QueryResolvers } from "./resolvers/index.js"; +import { jsonScalar } from "./resolvers/jsonScalar.js"; import { typeDefs } from "./schema.js"; import type { StreamGraphqlServerOptions } from "./types.js"; @@ -10,6 +11,7 @@ export function createStreamGraphqlServer(options: StreamGraphqlServerOptions): return new ApolloServer({ typeDefs, resolvers: { + JSON: jsonScalar, Query: { v1: () => ({}) }, V1Query: v1QueryResolvers, }, diff --git a/api/src/tests/flowAdjustmentQueries.test.ts b/api/src/tests/flowAdjustmentQueries.test.ts index aab552a..afc9f37 100644 --- a/api/src/tests/flowAdjustmentQueries.test.ts +++ b/api/src/tests/flowAdjustmentQueries.test.ts @@ -15,6 +15,7 @@ describe("flow adjustment graphql queries", () => { old_rate: "1000", new_rate: "1200", payload: '{"totalDebt":"1234"}', + payload_readable: '{"total_debt":"1234","old_rate":"1000","new_rate":"1200"}', ledger_number: "120", transaction_hash: "tx-10", event_index: 0, @@ -28,6 +29,7 @@ describe("flow adjustment graphql queries", () => { old_rate: "1200", new_rate: "1500", payload: '{"totalDebt":"1300"}', + payload_readable: '{"total_debt":"1300","old_rate":"1200","new_rate":"1500"}', ledger_number: "121", transaction_hash: "tx-11", event_index: 1, @@ -52,6 +54,7 @@ describe("flow adjustment graphql queries", () => { oldRate newRate payload + payloadReadable ledgerNumber transactionHash eventIndex @@ -83,6 +86,7 @@ describe("flow adjustment graphql queries", () => { oldRate: "1000", newRate: "1200", payload: '{"totalDebt":"1234"}', + payloadReadable: '{"total_debt":"1234","old_rate":"1000","new_rate":"1200"}', ledgerNumber: "120", transactionHash: "tx-10", eventIndex: 0, @@ -95,6 +99,7 @@ describe("flow adjustment graphql queries", () => { oldRate: "1200", newRate: "1500", payload: '{"totalDebt":"1300"}', + payloadReadable: '{"total_debt":"1300","old_rate":"1200","new_rate":"1500"}', ledgerNumber: "121", transactionHash: "tx-11", eventIndex: 1, @@ -105,7 +110,7 @@ describe("flow adjustment graphql queries", () => { expect(client.queries).toEqual([ { - text: "select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index, created_at from flow_adjustments where stream_id = $1 order by created_at asc limit $2 offset $3", + text: "select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from flow_adjustments where stream_id = $1 order by created_at asc limit $2 offset $3", values: ["7", 2, 0], }, ]); diff --git a/api/src/tests/lifecycleQueries.test.ts b/api/src/tests/lifecycleQueries.test.ts index 0e13ac3..06825c8 100644 --- a/api/src/tests/lifecycleQueries.test.ts +++ b/api/src/tests/lifecycleQueries.test.ts @@ -13,6 +13,7 @@ describe("lifecycle graphql queries", () => { stream_id: "7", event_name: "flow_adjusted", payload: '{"delta":10}', + payload_readable: '{"total_debt":"1234","old_rate":"1000","new_rate":"1200"}', ledger_number: "120", transaction_hash: "tx-10", event_index: 0, @@ -24,6 +25,7 @@ describe("lifecycle graphql queries", () => { stream_id: "7", event_name: "flow_paused", payload: '{"reason":"manual"}', + payload_readable: '{"sender":"sender","recipient":"recipient","total_debt":"1300"}', ledger_number: "121", transaction_hash: "tx-11", event_index: 1, @@ -46,6 +48,7 @@ describe("lifecycle graphql queries", () => { streamId eventName payload + payloadReadable ledgerNumber transactionHash eventIndex @@ -75,6 +78,7 @@ describe("lifecycle graphql queries", () => { streamId: "7", eventName: "flow_adjusted", payload: '{"delta":10}', + payloadReadable: '{"total_debt":"1234","old_rate":"1000","new_rate":"1200"}', ledgerNumber: "120", transactionHash: "tx-10", eventIndex: 0, @@ -85,6 +89,7 @@ describe("lifecycle graphql queries", () => { streamId: "7", eventName: "flow_paused", payload: '{"reason":"manual"}', + payloadReadable: '{"sender":"sender","recipient":"recipient","total_debt":"1300"}', ledgerNumber: "121", transactionHash: "tx-11", eventIndex: 1, @@ -95,7 +100,7 @@ describe("lifecycle graphql queries", () => { expect(client.queries).toEqual([ { - text: "select id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index, created_at from flow_events where stream_id = $1 order by created_at asc limit $2 offset $3", + text: "select id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from flow_events where stream_id = $1 order by created_at asc limit $2 offset $3", values: ["7", 2, 0], }, ]); @@ -113,6 +118,8 @@ describe("lifecycle graphql queries", () => { stream_id: "9", event_name: "lockup_created", payload: '{"lockupId":"abc"}', + payload_readable: + '{"sender":"sender","recipient":"recipient","token":"token","total_amount":"100","start_time":"2026-06-26T05:00:00.000Z","end_time":"2026-06-26T06:00:00.000Z","cliff_time":null,"cancelable":true}', ledger_number: "130", transaction_hash: "tx-20", event_index: 0, @@ -135,6 +142,7 @@ describe("lifecycle graphql queries", () => { streamId eventName payload + payloadReadable ledgerNumber transactionHash eventIndex @@ -164,6 +172,8 @@ describe("lifecycle graphql queries", () => { streamId: "9", eventName: "lockup_created", payload: '{"lockupId":"abc"}', + payloadReadable: + '{"sender":"sender","recipient":"recipient","token":"token","total_amount":"100","start_time":"2026-06-26T05:00:00.000Z","end_time":"2026-06-26T06:00:00.000Z","cliff_time":null,"cancelable":true}', ledgerNumber: "130", transactionHash: "tx-20", eventIndex: 0, @@ -174,7 +184,7 @@ describe("lifecycle graphql queries", () => { expect(client.queries).toEqual([ { - text: "select id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index, created_at from lockup_events where stream_id = $1 order by created_at asc limit $2 offset $3", + text: "select id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from lockup_events where stream_id = $1 order by created_at asc limit $2 offset $3", values: ["9", 1, 0], }, ]); diff --git a/api/src/tests/paginationQueries.test.ts b/api/src/tests/paginationQueries.test.ts index 57ee499..cee0a2e 100644 --- a/api/src/tests/paginationQueries.test.ts +++ b/api/src/tests/paginationQueries.test.ts @@ -16,11 +16,21 @@ describe("pagination graphql queries", () => { id: "flow-contract:8", contract_id: "flow-contract", stream_id: "8", + stream_type: "flow", sender: "sender-2", recipient: "recipient-2", token: "token-2", rate_per_second: "2000", + start_time: null, + end_time: null, snapshot_time: "456", + start_time_readable: null, + end_time_readable: null, + status: "active", + remaining_amount: "0", + snapshot_time_readable: "1970-01-01T00:07:36.000Z", + payload_raw: "{}", + payload_readable: "{}", created_at: new Date("2026-06-26T01:00:00.000Z"), updated_at: new Date("2026-06-26T01:00:00.000Z"), }, @@ -28,11 +38,21 @@ describe("pagination graphql queries", () => { id: "flow-contract:9", contract_id: "flow-contract", stream_id: "9", + stream_type: "flow", sender: "sender-3", recipient: "recipient-3", token: "token-3", rate_per_second: "3000", + start_time: null, + end_time: null, snapshot_time: "789", + start_time_readable: null, + end_time_readable: null, + status: "active", + remaining_amount: "0", + snapshot_time_readable: "1970-01-01T00:13:09.000Z", + payload_raw: "{}", + payload_readable: "{}", created_at: new Date("2026-06-26T02:00:00.000Z"), updated_at: new Date("2026-06-26T02:00:00.000Z"), }, @@ -106,7 +126,7 @@ describe("pagination graphql queries", () => { expect(client.queries).toEqual([ { - text: "select id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at from streams where contract_id = $1 and (created_at, id) > ($2, $3) order by created_at asc, id asc limit $4", + text: "select id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable, created_at, updated_at from streams where contract_id = $1 and (created_at, id) > ($2, $3) order by created_at asc, id asc limit $4", values: ["flow-contract", new Date("2026-06-26T00:00:00.000Z"), "flow-contract:7", 2], }, ]); @@ -212,7 +232,7 @@ describe("pagination graphql queries", () => { expect(client.queries).toEqual([ { - text: "select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index, created_at from flow_adjustments where stream_id = $1 and (created_at, id) > ($2, $3) order by created_at asc, id asc limit $4", + text: "select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from flow_adjustments where stream_id = $1 and (created_at, id) > ($2, $3) order by created_at asc, id asc limit $4", values: ["7", new Date("2026-06-26T03:00:00.000Z"), "flow-contract:7:adjustment:1", 2], }, ]); diff --git a/api/src/tests/server.test.ts b/api/src/tests/server.test.ts index 232ae72..d29ecc0 100644 --- a/api/src/tests/server.test.ts +++ b/api/src/tests/server.test.ts @@ -11,11 +11,23 @@ describe("stream graphql server", () => { id: "flow-contract:7", contract_id: "flow-contract", stream_id: "7", + stream_type: "lockup", sender: "sender", recipient: "recipient", token: "token", rate_per_second: "1000", + start_time: "1782983118", + end_time: "1783415118", snapshot_time: "123", + start_time_readable: "2026-07-02T09:05:18.000Z", + end_time_readable: "2026-07-07T09:05:18.000Z", + status: "active", + remaining_amount: "100", + snapshot_time_readable: "1970-01-01T00:02:03.000Z", + payload_raw: + '["sender","recipient","token","100",["1782983118","1783415118",null,true]]', + payload_readable: + '{"sender":"sender","recipient":"recipient","token":"token","total_amount":"100","start_time":"2026-07-02T09:05:18.000Z","end_time":"2026-07-07T09:05:18.000Z","cliff_time":null,"cancelable":true}', created_at: new Date("2026-06-26T00:00:00.000Z"), updated_at: new Date("2026-06-26T00:00:00.000Z"), }, @@ -34,11 +46,21 @@ describe("stream graphql server", () => { id contractId streamId + streamType sender recipient token ratePerSecond + startTime + endTime snapshotTime + startTimeReadable + endTimeReadable + status + remainingAmount + snapshotTimeReadable + payloadRaw + payloadReadable } } } @@ -60,18 +82,43 @@ describe("stream graphql server", () => { id: "flow-contract:7", contractId: "flow-contract", streamId: "7", + streamType: "lockup", sender: "sender", recipient: "recipient", token: "token", ratePerSecond: "1000", + startTime: "1782983118", + endTime: "1783415118", snapshotTime: "123", + startTimeReadable: "2026-07-02T09:05:18.000Z", + endTimeReadable: "2026-07-07T09:05:18.000Z", + status: "active", + remainingAmount: "100", + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", + payloadRaw: [ + "sender", + "recipient", + "token", + "100", + ["1782983118", "1783415118", null, true], + ], + payloadReadable: { + sender: "sender", + recipient: "recipient", + token: "token", + total_amount: "100", + start_time: "2026-07-02T09:05:18.000Z", + end_time: "2026-07-07T09:05:18.000Z", + cliff_time: null, + cancelable: true, + }, }, }, }); expect(client.queries).toEqual([ { - text: "select id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at from streams where id = $1 limit 1", + text: "select id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable, created_at, updated_at from streams where id = $1 limit 1", values: ["flow-contract:7"], }, ]); @@ -87,11 +134,21 @@ describe("stream graphql server", () => { id: "flow-contract:7", contract_id: "flow-contract", stream_id: "7", + stream_type: "flow", sender: "sender-1", recipient: "recipient-1", token: "token-1", rate_per_second: "1000", + start_time: null, + end_time: null, snapshot_time: "123", + start_time_readable: null, + end_time_readable: null, + status: "active", + remaining_amount: "0", + snapshot_time_readable: "1970-01-01T00:02:03.000Z", + payload_raw: "{}", + payload_readable: "{}", created_at: new Date("2026-06-26T00:00:00.000Z"), updated_at: new Date("2026-06-26T00:00:00.000Z"), }, @@ -99,11 +156,21 @@ describe("stream graphql server", () => { id: "flow-contract:8", contract_id: "flow-contract", stream_id: "8", + stream_type: "flow", sender: "sender-2", recipient: "recipient-2", token: "token-2", rate_per_second: "2000", + start_time: null, + end_time: null, snapshot_time: "456", + start_time_readable: null, + end_time_readable: null, + status: "active", + remaining_amount: "0", + snapshot_time_readable: "1970-01-01T00:07:36.000Z", + payload_raw: "{}", + payload_readable: "{}", created_at: new Date("2026-06-26T01:00:00.000Z"), updated_at: new Date("2026-06-26T01:00:00.000Z"), }, @@ -127,6 +194,9 @@ describe("stream graphql server", () => { token ratePerSecond snapshotTime + startTimeReadable + endTimeReadable + snapshotTimeReadable } } } @@ -151,6 +221,9 @@ describe("stream graphql server", () => { token: "token-1", ratePerSecond: "1000", snapshotTime: "123", + startTimeReadable: null, + endTimeReadable: null, + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", }, { id: "flow-contract:8", @@ -161,6 +234,9 @@ describe("stream graphql server", () => { token: "token-2", ratePerSecond: "2000", snapshotTime: "456", + startTimeReadable: null, + endTimeReadable: null, + snapshotTimeReadable: "1970-01-01T00:07:36.000Z", }, ], }, @@ -168,7 +244,7 @@ describe("stream graphql server", () => { expect(client.queries).toEqual([ { - text: "select id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at from streams order by created_at asc", + text: "select id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable, created_at, updated_at from streams order by created_at asc", values: [], }, ]); @@ -184,11 +260,21 @@ describe("stream graphql server", () => { id: "flow-contract:8", contract_id: "flow-contract", stream_id: "8", + stream_type: "flow", sender: "sender-2", recipient: "recipient-2", token: "token-2", rate_per_second: "2000", + start_time: null, + end_time: null, snapshot_time: "456", + start_time_readable: null, + end_time_readable: null, + status: "active", + remaining_amount: "0", + snapshot_time_readable: "1970-01-01T00:07:36.000Z", + payload_raw: "{}", + payload_readable: "{}", created_at: new Date("2026-06-26T01:00:00.000Z"), updated_at: new Date("2026-06-26T01:00:00.000Z"), }, @@ -212,6 +298,9 @@ describe("stream graphql server", () => { token ratePerSecond snapshotTime + startTimeReadable + endTimeReadable + snapshotTimeReadable } } } @@ -241,6 +330,9 @@ describe("stream graphql server", () => { token: "token-2", ratePerSecond: "2000", snapshotTime: "456", + startTimeReadable: null, + endTimeReadable: null, + snapshotTimeReadable: "1970-01-01T00:07:36.000Z", }, ], }, @@ -248,7 +340,7 @@ describe("stream graphql server", () => { expect(client.queries).toEqual([ { - text: "select id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at from streams where contract_id = $1 order by created_at asc limit $2 offset $3", + text: "select id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable, created_at, updated_at from streams where contract_id = $1 order by created_at asc limit $2 offset $3", values: ["flow-contract", 1, 2], }, ]); diff --git a/api/src/types.ts b/api/src/types.ts index 91733ca..d38775d 100644 --- a/api/src/types.ts +++ b/api/src/types.ts @@ -6,11 +6,21 @@ export type StreamRow = { id: string; contract_id: string; stream_id: string | number | bigint; + stream_type: string; sender: string; recipient: string; token: string; rate_per_second: string; + start_time: string | number | bigint | null; + end_time: string | number | bigint | null; snapshot_time: string | number | bigint; + start_time_readable: string | null; + end_time_readable: string | null; + status: string; + remaining_amount: string; + snapshot_time_readable: string; + payload_raw: string; + payload_readable: string; created_at: Date; updated_at: Date; }; @@ -67,6 +77,7 @@ export type FlowEventRow = { stream_id: string | number | bigint; event_name: string; payload: string; + payload_readable: string; ledger_number: string | number | bigint; transaction_hash: string; event_index: number; @@ -81,6 +92,7 @@ export type FlowAdjustmentRow = { old_rate: string; new_rate: string; payload: string; + payload_readable: string; ledger_number: string | number | bigint; transaction_hash: string; event_index: number; @@ -93,6 +105,7 @@ export type LockupEventRow = { stream_id: string | number | bigint; event_name: string; payload: string; + payload_readable: string; ledger_number: string | number | bigint; transaction_hash: string; event_index: number; diff --git a/common/src/config/runtimeEnv.ts b/common/src/config/runtimeEnv.ts new file mode 100644 index 0000000..998844f --- /dev/null +++ b/common/src/config/runtimeEnv.ts @@ -0,0 +1,60 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import type { RuntimeEnv } from "./types.js"; + +const runtimeEnvFiles = [".env", "../.env", "../../.env"]; + +export function loadRuntimeEnv(baseDir: string, env: RuntimeEnv = process.env): RuntimeEnv { + const mergedEnv: RuntimeEnv = { ...env }; + + for (const relativePath of runtimeEnvFiles) { + const filePath = resolve(baseDir, relativePath); + + if (!existsSync(filePath)) { + continue; + } + + const fileEnv = parseEnvFile(readFileSync(filePath, "utf8")); + + for (const [key, value] of Object.entries(fileEnv)) { + if (mergedEnv[key] === undefined) { + mergedEnv[key] = value; + } + } + } + + return mergedEnv; +} + +function parseEnvFile(contents: string): RuntimeEnv { + const env: RuntimeEnv = {}; + + for (const line of contents.split(/\r?\n/)) { + const trimmed = line.trim(); + + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + + const equalsIndex = trimmed.indexOf("="); + + if (equalsIndex <= 0) { + continue; + } + + const key = trimmed.slice(0, equalsIndex).trim(); + let value = trimmed.slice(equalsIndex + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + env[key] = value; + } + + return env; +} diff --git a/common/src/config/tests/runtimeEnv.test.ts b/common/src/config/tests/runtimeEnv.test.ts new file mode 100644 index 0000000..b161140 --- /dev/null +++ b/common/src/config/tests/runtimeEnv.test.ts @@ -0,0 +1,32 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { loadRuntimeEnv } from "../runtimeEnv.js"; + +describe("loadRuntimeEnv", () => { + test("loads values from env files without overriding explicit environment values", () => { + const baseDir = mkdtempSync(join(tmpdir(), "fundable-indexer-env-")); + mkdirSync(join(baseDir, "child"), { recursive: true }); + + writeFileSync(join(baseDir, ".env"), "DATABASE_URL=postgres://root-file\nLOG_LEVEL=warn\n"); + writeFileSync( + join(baseDir, "child", ".env"), + "DATABASE_URL=postgres://child-file\nSOROBAN_RPC_URL=https://child.example\n", + ); + + const env = loadRuntimeEnv(join(baseDir, "child"), { + DATABASE_URL: "postgres://explicit", + STELLAR_NETWORK_PASSPHRASE: "explicit-passphrase", + }); + + expect(env).toMatchObject({ + DATABASE_URL: "postgres://explicit", + LOG_LEVEL: "warn", + SOROBAN_RPC_URL: "https://child.example", + STELLAR_NETWORK_PASSPHRASE: "explicit-passphrase", + }); + }); +}); diff --git a/common/src/db/migrations/0001_add_streams_table.sql b/common/src/db/migrations/0001_add_streams_table.sql deleted file mode 100644 index 829db71..0000000 --- a/common/src/db/migrations/0001_add_streams_table.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE "streams" ( - "id" text PRIMARY KEY NOT NULL, - "contract_id" text NOT NULL, - "stream_id" bigint NOT NULL, - "sender" text NOT NULL, - "recipient" text NOT NULL, - "token" text NOT NULL, - "rate_per_second" text NOT NULL, - "snapshot_time" bigint NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX "streams_contract_stream_unique" ON "streams" USING btree ("contract_id","stream_id"); diff --git a/common/src/db/migrations/0001_yellow_nova.sql b/common/src/db/migrations/0001_yellow_nova.sql new file mode 100644 index 0000000..127725e --- /dev/null +++ b/common/src/db/migrations/0001_yellow_nova.sql @@ -0,0 +1,97 @@ +CREATE TABLE "flow_adjustments" ( + "id" text PRIMARY KEY NOT NULL, + "contract_id" text NOT NULL, + "stream_id" bigint NOT NULL, + "total_debt" text NOT NULL, + "old_rate" text NOT NULL, + "new_rate" text NOT NULL, + "payload" text NOT NULL, + "ledger_number" bigint NOT NULL, + "transaction_hash" text NOT NULL, + "event_index" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "flow_deposits" ( + "id" text PRIMARY KEY NOT NULL, + "contract_id" text NOT NULL, + "stream_id" bigint NOT NULL, + "funder" text NOT NULL, + "amount" text NOT NULL, + "ledger_number" bigint NOT NULL, + "transaction_hash" text NOT NULL, + "event_index" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "flow_events" ( + "id" text PRIMARY KEY NOT NULL, + "contract_id" text NOT NULL, + "stream_id" bigint NOT NULL, + "event_name" text NOT NULL, + "payload" text NOT NULL, + "ledger_number" bigint NOT NULL, + "transaction_hash" text NOT NULL, + "event_index" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "flow_withdrawals" ( + "id" text PRIMARY KEY NOT NULL, + "contract_id" text NOT NULL, + "stream_id" bigint NOT NULL, + "to_address" text NOT NULL, + "caller" text NOT NULL, + "amount" text NOT NULL, + "ledger_number" bigint NOT NULL, + "transaction_hash" text NOT NULL, + "event_index" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "lockup_events" ( + "id" text PRIMARY KEY NOT NULL, + "contract_id" text NOT NULL, + "stream_id" bigint NOT NULL, + "event_name" text NOT NULL, + "payload" text NOT NULL, + "ledger_number" bigint NOT NULL, + "transaction_hash" text NOT NULL, + "event_index" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "nft_transfers" ( + "id" text PRIMARY KEY NOT NULL, + "contract_id" text NOT NULL, + "from_address" text NOT NULL, + "to_address" text NOT NULL, + "token_id" bigint NOT NULL, + "ledger_number" bigint NOT NULL, + "transaction_hash" text NOT NULL, + "event_index" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "streams" ( + "id" text PRIMARY KEY NOT NULL, + "contract_id" text NOT NULL, + "stream_id" bigint NOT NULL, + "sender" text NOT NULL, + "recipient" text NOT NULL, + "token" text NOT NULL, + "rate_per_second" text NOT NULL, + "snapshot_time" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "flow_adjustments_stream_created_idx" ON "flow_adjustments" USING btree ("stream_id","created_at","id");--> statement-breakpoint +CREATE INDEX "flow_deposits_stream_created_idx" ON "flow_deposits" USING btree ("stream_id","created_at","id");--> statement-breakpoint +CREATE INDEX "flow_events_stream_created_idx" ON "flow_events" USING btree ("stream_id","created_at","id");--> statement-breakpoint +CREATE INDEX "flow_withdrawals_stream_created_idx" ON "flow_withdrawals" USING btree ("stream_id","created_at","id");--> statement-breakpoint +CREATE INDEX "lockup_events_stream_created_idx" ON "lockup_events" USING btree ("stream_id","created_at","id");--> statement-breakpoint +CREATE INDEX "nft_transfers_contract_token_created_idx" ON "nft_transfers" USING btree ("contract_id","token_id","created_at","id");--> statement-breakpoint +CREATE UNIQUE INDEX "streams_contract_stream_unique" ON "streams" USING btree ("contract_id","stream_id");--> statement-breakpoint +CREATE INDEX "streams_contract_created_idx" ON "streams" USING btree ("contract_id","created_at","id");--> statement-breakpoint +CREATE INDEX "streams_created_idx" ON "streams" USING btree ("created_at","id"); \ No newline at end of file diff --git a/common/src/db/migrations/0002_green_nicolaos.sql b/common/src/db/migrations/0002_green_nicolaos.sql new file mode 100644 index 0000000..2a7d00e --- /dev/null +++ b/common/src/db/migrations/0002_green_nicolaos.sql @@ -0,0 +1,10 @@ +ALTER TABLE "flow_adjustments" ADD COLUMN "payload_readable" text DEFAULT '{}' NOT NULL;--> statement-breakpoint +ALTER TABLE "flow_events" ADD COLUMN "payload_readable" text DEFAULT '{}' NOT NULL;--> statement-breakpoint +ALTER TABLE "lockup_events" ADD COLUMN "payload_readable" text DEFAULT '{}' NOT NULL;--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "stream_type" text DEFAULT 'flow' NOT NULL;--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "start_time" bigint;--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "end_time" bigint;--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "status" text DEFAULT 'active' NOT NULL;--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "remaining_amount" text DEFAULT '0' NOT NULL;--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "payload_raw" text DEFAULT '{}' NOT NULL;--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "payload_readable" text DEFAULT '{}' NOT NULL; \ No newline at end of file diff --git a/common/src/db/migrations/0003_charming_ben_parker.sql b/common/src/db/migrations/0003_charming_ben_parker.sql new file mode 100644 index 0000000..3a44a76 --- /dev/null +++ b/common/src/db/migrations/0003_charming_ben_parker.sql @@ -0,0 +1,15 @@ +ALTER TABLE "streams" ADD COLUMN "start_time_readable" text;--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "end_time_readable" text;--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "snapshot_time_readable" text DEFAULT '' NOT NULL;--> statement-breakpoint +UPDATE "streams" +SET + "start_time_readable" = CASE + WHEN "start_time" IS NULL THEN NULL + ELSE to_char(timezone('utc', to_timestamp("start_time")), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') + END, + "end_time_readable" = CASE + WHEN "end_time" IS NULL THEN NULL + ELSE to_char(timezone('utc', to_timestamp("end_time")), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') + END, + "snapshot_time_readable" = to_char(timezone('utc', to_timestamp("snapshot_time")), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') +WHERE "snapshot_time_readable" = ''; diff --git a/common/src/db/migrations/0006_add_readable_payload_fields.sql b/common/src/db/migrations/0006_add_readable_payload_fields.sql new file mode 100644 index 0000000..dbe554c --- /dev/null +++ b/common/src/db/migrations/0006_add_readable_payload_fields.sql @@ -0,0 +1,5 @@ +ALTER TABLE "flow_adjustments" ADD COLUMN "payload_readable" text DEFAULT '{}' NOT NULL; +--> statement-breakpoint +ALTER TABLE "flow_events" ADD COLUMN "payload_readable" text DEFAULT '{}' NOT NULL; +--> statement-breakpoint +ALTER TABLE "lockup_events" ADD COLUMN "payload_readable" text DEFAULT '{}' NOT NULL; diff --git a/common/src/db/migrations/0007_add_stream_snapshot_fields.sql b/common/src/db/migrations/0007_add_stream_snapshot_fields.sql new file mode 100644 index 0000000..e079944 --- /dev/null +++ b/common/src/db/migrations/0007_add_stream_snapshot_fields.sql @@ -0,0 +1,13 @@ +ALTER TABLE "streams" ADD COLUMN "stream_type" text DEFAULT 'flow' NOT NULL; +--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "start_time" bigint; +--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "end_time" bigint; +--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "status" text DEFAULT 'active' NOT NULL; +--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "remaining_amount" text DEFAULT '0' NOT NULL; +--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "payload_raw" text DEFAULT '{}' NOT NULL; +--> statement-breakpoint +ALTER TABLE "streams" ADD COLUMN "payload_readable" text DEFAULT '{}' NOT NULL; diff --git a/common/src/db/migrations/0008_add_stream_time_readable_fields.sql b/common/src/db/migrations/0008_add_stream_time_readable_fields.sql new file mode 100644 index 0000000..2589c2a --- /dev/null +++ b/common/src/db/migrations/0008_add_stream_time_readable_fields.sql @@ -0,0 +1,22 @@ +alter table "streams" add column "start_time_readable" text; +alter table "streams" add column "end_time_readable" text; +alter table "streams" add column "snapshot_time_readable" text; + +update "streams" +set + "start_time_readable" = case + when "start_time" is null then null + else to_char(timezone('utc', to_timestamp("start_time")), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') + end, + "end_time_readable" = case + when "end_time" is null then null + else to_char(timezone('utc', to_timestamp("end_time")), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') + end, + "snapshot_time_readable" = to_char(timezone('utc', to_timestamp("snapshot_time")), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') +where "snapshot_time_readable" is null or "snapshot_time_readable" = ''; + +alter table "streams" + alter column "snapshot_time_readable" set default ''; + +alter table "streams" + alter column "snapshot_time_readable" set not null; diff --git a/common/src/db/migrations/meta/0001_snapshot.json b/common/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..373c58e --- /dev/null +++ b/common/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,887 @@ +{ + "id": "0f240d12-f0f7-451d-8dab-235740f67523", + "prevId": "8e720423-afb1-441f-a13e-7b85d413a5ba", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.flow_adjustments": { + "name": "flow_adjustments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "total_debt": { + "name": "total_debt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_rate": { + "name": "old_rate", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_rate": { + "name": "new_rate", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flow_adjustments_stream_created_idx": { + "name": "flow_adjustments_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flow_deposits": { + "name": "flow_deposits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "funder": { + "name": "funder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flow_deposits_stream_created_idx": { + "name": "flow_deposits_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flow_events": { + "name": "flow_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flow_events_stream_created_idx": { + "name": "flow_events_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flow_withdrawals": { + "name": "flow_withdrawals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "to_address": { + "name": "to_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller": { + "name": "caller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flow_withdrawals_stream_created_idx": { + "name": "flow_withdrawals_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.indexed_events": { + "name": "indexed_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "indexed_events_identity_unique": { + "name": "indexed_events_identity_unique", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ledger_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transaction_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.indexer_cursors": { + "name": "indexer_cursors", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_processed_ledger": { + "name": "last_processed_ledger", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lockup_events": { + "name": "lockup_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lockup_events_stream_created_idx": { + "name": "lockup_events_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nft_transfers": { + "name": "nft_transfers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_address": { + "name": "from_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_address": { + "name": "to_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_id": { + "name": "token_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "nft_transfers_contract_token_created_idx": { + "name": "nft_transfers_contract_token_created_idx", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.streams": { + "name": "streams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sender": { + "name": "sender", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rate_per_second": { + "name": "rate_per_second", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_time": { + "name": "snapshot_time", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "streams_contract_stream_unique": { + "name": "streams_contract_stream_unique", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "streams_contract_created_idx": { + "name": "streams_contract_created_idx", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "streams_created_idx": { + "name": "streams_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/common/src/db/migrations/meta/0002_snapshot.json b/common/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..54125a0 --- /dev/null +++ b/common/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,955 @@ +{ + "id": "89ff46bd-0bdb-4b33-bf70-c31b0bb4dc0a", + "prevId": "0f240d12-f0f7-451d-8dab-235740f67523", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.flow_adjustments": { + "name": "flow_adjustments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "total_debt": { + "name": "total_debt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_rate": { + "name": "old_rate", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_rate": { + "name": "new_rate", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_readable": { + "name": "payload_readable", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flow_adjustments_stream_created_idx": { + "name": "flow_adjustments_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flow_deposits": { + "name": "flow_deposits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "funder": { + "name": "funder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flow_deposits_stream_created_idx": { + "name": "flow_deposits_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flow_events": { + "name": "flow_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_readable": { + "name": "payload_readable", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flow_events_stream_created_idx": { + "name": "flow_events_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flow_withdrawals": { + "name": "flow_withdrawals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "to_address": { + "name": "to_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller": { + "name": "caller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flow_withdrawals_stream_created_idx": { + "name": "flow_withdrawals_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.indexed_events": { + "name": "indexed_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "indexed_events_identity_unique": { + "name": "indexed_events_identity_unique", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ledger_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transaction_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.indexer_cursors": { + "name": "indexer_cursors", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_processed_ledger": { + "name": "last_processed_ledger", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lockup_events": { + "name": "lockup_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_readable": { + "name": "payload_readable", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lockup_events_stream_created_idx": { + "name": "lockup_events_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nft_transfers": { + "name": "nft_transfers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_address": { + "name": "from_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_address": { + "name": "to_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_id": { + "name": "token_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "nft_transfers_contract_token_created_idx": { + "name": "nft_transfers_contract_token_created_idx", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.streams": { + "name": "streams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "stream_type": { + "name": "stream_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'flow'" + }, + "sender": { + "name": "sender", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rate_per_second": { + "name": "rate_per_second", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "end_time": { + "name": "end_time", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "remaining_amount": { + "name": "remaining_amount", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "snapshot_time": { + "name": "snapshot_time", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "payload_raw": { + "name": "payload_raw", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "payload_readable": { + "name": "payload_readable", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "streams_contract_stream_unique": { + "name": "streams_contract_stream_unique", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "streams_contract_created_idx": { + "name": "streams_contract_created_idx", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "streams_created_idx": { + "name": "streams_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/common/src/db/migrations/meta/0003_snapshot.json b/common/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..9490084 --- /dev/null +++ b/common/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,974 @@ +{ + "id": "0f20cf5d-b235-498b-9b35-0933762c8260", + "prevId": "89ff46bd-0bdb-4b33-bf70-c31b0bb4dc0a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.flow_adjustments": { + "name": "flow_adjustments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "total_debt": { + "name": "total_debt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_rate": { + "name": "old_rate", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_rate": { + "name": "new_rate", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_readable": { + "name": "payload_readable", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flow_adjustments_stream_created_idx": { + "name": "flow_adjustments_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flow_deposits": { + "name": "flow_deposits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "funder": { + "name": "funder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flow_deposits_stream_created_idx": { + "name": "flow_deposits_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flow_events": { + "name": "flow_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_readable": { + "name": "payload_readable", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flow_events_stream_created_idx": { + "name": "flow_events_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flow_withdrawals": { + "name": "flow_withdrawals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "to_address": { + "name": "to_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller": { + "name": "caller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flow_withdrawals_stream_created_idx": { + "name": "flow_withdrawals_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.indexed_events": { + "name": "indexed_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "indexed_events_identity_unique": { + "name": "indexed_events_identity_unique", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ledger_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transaction_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.indexer_cursors": { + "name": "indexer_cursors", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_processed_ledger": { + "name": "last_processed_ledger", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lockup_events": { + "name": "lockup_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_readable": { + "name": "payload_readable", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lockup_events_stream_created_idx": { + "name": "lockup_events_stream_created_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nft_transfers": { + "name": "nft_transfers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_address": { + "name": "from_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_address": { + "name": "to_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_id": { + "name": "token_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ledger_number": { + "name": "ledger_number", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "nft_transfers_contract_token_created_idx": { + "name": "nft_transfers_contract_token_created_idx", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.streams": { + "name": "streams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "stream_type": { + "name": "stream_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'flow'" + }, + "sender": { + "name": "sender", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rate_per_second": { + "name": "rate_per_second", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "end_time": { + "name": "end_time", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "start_time_readable": { + "name": "start_time_readable", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "end_time_readable": { + "name": "end_time_readable", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "remaining_amount": { + "name": "remaining_amount", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "snapshot_time": { + "name": "snapshot_time", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "snapshot_time_readable": { + "name": "snapshot_time_readable", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "payload_raw": { + "name": "payload_raw", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "payload_readable": { + "name": "payload_readable", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "streams_contract_stream_unique": { + "name": "streams_contract_stream_unique", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "streams_contract_created_idx": { + "name": "streams_contract_created_idx", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "streams_created_idx": { + "name": "streams_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/common/src/db/migrations/meta/_journal.json b/common/src/db/migrations/meta/_journal.json index 57767f8..b66d0ca 100644 --- a/common/src/db/migrations/meta/_journal.json +++ b/common/src/db/migrations/meta/_journal.json @@ -8,6 +8,27 @@ "when": 1782506420853, "tag": "0000_puzzling_madelyne_pryor", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1782984701421, + "tag": "0001_yellow_nova", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1783101795981, + "tag": "0002_green_nicolaos", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1783261784177, + "tag": "0003_charming_ben_parker", + "breakpoints": true } ] } diff --git a/common/src/db/schema.ts b/common/src/db/schema.ts index f0638d8..639faa6 100644 --- a/common/src/db/schema.ts +++ b/common/src/db/schema.ts @@ -37,11 +37,21 @@ export const streams = pgTable( id: text("id").primaryKey(), contractId: text("contract_id").notNull(), streamId: bigint("stream_id", { mode: "bigint" }).notNull(), + streamType: text("stream_type").notNull().default("flow"), sender: text("sender").notNull(), recipient: text("recipient").notNull(), token: text("token").notNull(), ratePerSecond: text("rate_per_second").notNull(), + startTime: bigint("start_time", { mode: "bigint" }), + endTime: bigint("end_time", { mode: "bigint" }), + startTimeReadable: text("start_time_readable"), + endTimeReadable: text("end_time_readable"), + status: text("status").notNull().default("active"), + remainingAmount: text("remaining_amount").notNull().default("0"), snapshotTime: bigint("snapshot_time", { mode: "bigint" }).notNull(), + snapshotTimeReadable: text("snapshot_time_readable").notNull().default(""), + payloadRaw: text("payload_raw").notNull().default("{}"), + payloadReadable: text("payload_readable").notNull().default("{}"), ...timestamps, updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, @@ -80,6 +90,7 @@ export const flowAdjustments = pgTable( oldRate: text("old_rate").notNull(), newRate: text("new_rate").notNull(), payload: text("payload").notNull(), + payloadReadable: text("payload_readable").notNull().default("{}"), ledgerNumber: bigint("ledger_number", { mode: "bigint" }).notNull(), transactionHash: text("transaction_hash").notNull(), eventIndex: integer("event_index").notNull(), @@ -98,6 +109,7 @@ export const flowEvents = pgTable( streamId: bigint("stream_id", { mode: "bigint" }).notNull(), eventName: text("event_name").notNull(), payload: text("payload").notNull(), + payloadReadable: text("payload_readable").notNull().default("{}"), ledgerNumber: bigint("ledger_number", { mode: "bigint" }).notNull(), transactionHash: text("transaction_hash").notNull(), eventIndex: integer("event_index").notNull(), @@ -135,6 +147,7 @@ export const lockupEvents = pgTable( streamId: bigint("stream_id", { mode: "bigint" }).notNull(), eventName: text("event_name").notNull(), payload: text("payload").notNull(), + payloadReadable: text("payload_readable").notNull().default("{}"), ledgerNumber: bigint("ledger_number", { mode: "bigint" }).notNull(), transactionHash: text("transaction_hash").notNull(), eventIndex: integer("event_index").notNull(), diff --git a/common/src/db/tests/schema.test.ts b/common/src/db/tests/schema.test.ts index 630e01c..7769f80 100644 --- a/common/src/db/tests/schema.test.ts +++ b/common/src/db/tests/schema.test.ts @@ -61,11 +61,21 @@ describe("shared database schema", () => { "id", "contractId", "streamId", + "streamType", "sender", "recipient", "token", "ratePerSecond", + "startTime", + "endTime", + "startTimeReadable", + "endTimeReadable", + "status", + "remainingAmount", "snapshotTime", + "snapshotTimeReadable", + "payloadRaw", + "payloadReadable", "createdAt", "updatedAt", ]); @@ -98,6 +108,7 @@ describe("shared database schema", () => { "oldRate", "newRate", "payload", + "payloadReadable", "ledgerNumber", "transactionHash", "eventIndex", @@ -131,6 +142,7 @@ describe("shared database schema", () => { "streamId", "eventName", "payload", + "payloadReadable", "ledgerNumber", "transactionHash", "eventIndex", @@ -147,6 +159,7 @@ describe("shared database schema", () => { "streamId", "eventName", "payload", + "payloadReadable", "ledgerNumber", "transactionHash", "eventIndex", diff --git a/common/src/index.ts b/common/src/index.ts index 9990b82..89c02cc 100644 --- a/common/src/index.ts +++ b/common/src/index.ts @@ -4,6 +4,7 @@ export const commonPackage = { } as const; export { loadConfig } from "./config/index.js"; +export { loadRuntimeEnv } from "./config/runtimeEnv.js"; export { createPostgresCursorStorage } from "./cursor/postgresStorage.js"; export { createCursorRepository } from "./cursor/repository.js"; export { createDatabasePool, schema } from "./db/index.js"; diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..836970e --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,16 @@ +NODE_ENV=production +APP_ENV=production +LOG_LEVEL=info +PORT=4000 +POLL_INTERVAL_MS=5000 + +# For host-installed Postgres, containers should use host.docker.internal. +DATABASE_URL=postgresql://fundable_indexer_user:use-a-strong-password@host.docker.internal:5432/fundable_indexer +SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 + +START_LEDGER= +FLOW_CONTRACT_ID= +LOCKUP_CONTRACT_ID= +STREAM_NFT_CONTRACT_ID= +ROUTER_CONTRACT_ID= diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..98293fc --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -euo pipefail + +DEPLOY_ENV="${DEPLOY_ENV:-${APP_ENV:-production}}" +DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}" +APP_ROOT="${APP_ROOT:-/opt/fundable-indexer}" +ENV_FILE="${ENV_FILE:-deploy/.env}" + +cd "$APP_ROOT" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing environment file: $APP_ROOT/$ENV_FILE" >&2 + exit 1 +fi + +git fetch origin "$DEPLOY_BRANCH" +git reset --hard "origin/$DEPLOY_BRANCH" + +bun install --frozen-lockfile + +export APP_ENV="$DEPLOY_ENV" +export DEPLOY_ENV +export DATABASE_URL="$(sed -n 's/^DATABASE_URL=//p' "$ENV_FILE" | head -n1)" +export COMPOSE_ENV_FILE="${COMPOSE_ENV_FILE:-$(basename "$ENV_FILE")}" +bun run db:migrate + +docker compose -f deploy/docker-compose.yml up -d --build --remove-orphans diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..4c059ef --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,27 @@ +services: + api: + build: + context: .. + dockerfile: api/Dockerfile + extra_hosts: + - "host.docker.internal:host-gateway" + env_file: + - ${COMPOSE_ENV_FILE:-.env} + environment: + APP_ENV: ${APP_ENV:-production} + PORT: ${PORT:-4000} + ports: + - "127.0.0.1:${PORT:-4000}:${PORT:-4000}" + restart: unless-stopped + + streams: + build: + context: .. + dockerfile: streams/Dockerfile + extra_hosts: + - "host.docker.internal:host-gateway" + env_file: + - ${COMPOSE_ENV_FILE:-.env} + environment: + APP_ENV: ${APP_ENV:-production} + restart: unless-stopped diff --git a/docs/plans/2026-06-28-ci-cd-deployment-design.md b/docs/plans/2026-06-28-ci-cd-deployment-design.md new file mode 100644 index 0000000..2b9c51b --- /dev/null +++ b/docs/plans/2026-06-28-ci-cd-deployment-design.md @@ -0,0 +1,101 @@ +# CI/CD Deployment Pipeline Design + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an automatic CI/CD pipeline for the indexer repo so pull requests are verified in GitHub Actions and merges to `main` deploy the app to a private Contabo VPS. + +**Architecture:** Use GitHub Actions for two separate concerns: CI on every pull request and automated deployment on `main`. The VPS runs this repo as an isolated Docker Compose project with two containers: `api` for GraphQL and `streams` for the indexer worker. The deploy workflow SSHes into the VPS, updates the checkout, applies database migrations, and restarts the compose stack. Only the API service is exposed publicly through the VPS reverse proxy; the worker stays private. + +**Tech Stack:** GitHub Actions, SSH deploys, Docker Compose, Bun, Turbo, Drizzle migrations, Postgres, Contabo VPS + +--- + +### Task 1: Add the CI workflow + +**Files:** +- Create: `.github/workflows/ci.yml` + +**Step 1: Write the failing workflow** + +Define a PR workflow that runs: +- `bun install` +- `bun run lint` +- `bun run type-check` +- `bun run test` + +Trigger it on pull requests and on pushes to `main`. + +**Step 2: Run it mentally against the repo** + +Expected behavior: +- CI fails if lint, type-check, or tests fail +- CI does not deploy anything + +### Task 2: Add the deploy workflow + +**Files:** +- Create: `.github/workflows/deploy.yml` + +**Step 1: Write the deployment workflow** + +Trigger deployment only on pushes to `main`. + +The deploy job should: +- SSH into the Contabo VPS +- enter the repo directory, for example `/opt/fundable-indexer` +- update the checkout from `origin/main` +- install dependencies if needed +- run database migrations +- restart the Docker Compose stack + +**Step 2: Add secrets and environment requirements** + +Document the required GitHub secrets: +- `VPS_HOST` +- `VPS_USER` +- `VPS_SSH_KEY` +- `VPS_PORT` if non-default +- any app secrets needed by the VPS environment + +### Task 3: Add the VPS runtime layout + +**Files:** +- Create: `deploy/docker-compose.yml` +- Create: `deploy/.env.example` +- Modify: `README.md` + +**Step 1: Define the runtime services** + +Add two services: +- `api` +- `streams` + +Keep the API service publicly reachable through the reverse proxy. Keep the worker private. + +**Step 2: Document the server layout** + +Explain: +- where the repo lives on the VPS +- how environment variables are supplied +- how migrations run +- how the services are restarted + +### Task 4: Verify and document the release path + +**Files:** +- Modify: `README.md` +- Modify: `docs/stream-indexer.md` + +**Step 1: Document how CI/CD works** + +Explain: +- PR checks +- main-branch deploys +- how a deploy fails safely +- how to roll back on the VPS + +**Step 2: Run repo verification** + +Run: `bun run lint && bun run type-check && bun run test` +Expected: PASS + diff --git a/docs/plans/2026-06-28-ci-cd-deployment.md b/docs/plans/2026-06-28-ci-cd-deployment.md new file mode 100644 index 0000000..91c8f8f --- /dev/null +++ b/docs/plans/2026-06-28-ci-cd-deployment.md @@ -0,0 +1,125 @@ +# CI/CD Deployment Pipeline Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add GitHub Actions CI for pull requests and an automatic deploy path to a private Contabo VPS using Docker Compose and SSH. + +**Architecture:** GitHub Actions will run repo checks on every pull request and `main` push. A separate deploy workflow will SSH into the Contabo VPS, update the repo checkout, run migrations, and restart the Docker Compose stack. The VPS stack will be isolated per app, with `api` exposed publicly through the existing reverse proxy and `streams` kept internal. + +**Tech Stack:** GitHub Actions, SSH, Docker Compose, Bun, Turbo, Drizzle migrations, Postgres, Contabo VPS + +--- + +### Task 1: Add the CI workflow + +**Files:** +- Create: `.github/workflows/ci.yml` + +**Step 1: Write the failing workflow** + +Add a workflow that runs on: +- pull requests +- pushes to `main` + +Jobs should run: +- `bun install` +- `bun run lint` +- `bun run type-check` +- `bun run test` + +**Step 2: Verify the job shape** + +Expected behavior: +- CI fails if any check fails +- CI does not deploy anything +- CI uses the repo’s existing Bun/Turbo scripts + +### Task 2: Add the Docker runtime files + +**Files:** +- Create: `api/Dockerfile` +- Create: `streams/Dockerfile` +- Create: `deploy/docker-compose.yml` +- Create: `deploy/.env.example` + +**Step 1: Write the Dockerfiles** + +Build each package in its own image so the API and stream worker can restart independently. + +**Step 2: Write the compose file** + +Define at least: +- `api` +- `streams` +- `postgres` only if the VPS does not already provide an external managed DB + +Use environment variables from a VPS `.env` file. + +**Step 3: Verify the runtime assumptions** + +Expected behavior: +- `api` can start with the same env vars used locally +- `streams` can start independently +- the compose project has stable container names and restart policy + +### Task 3: Add the deployment script + +**Files:** +- Create: `deploy/deploy.sh` +- Modify: `README.md` + +**Step 1: Write the remote deploy script** + +The script should: +- `cd` into `/opt/fundable-indexer` +- `git fetch` and reset/pull `origin/main` +- install dependencies if needed +- run migrations +- rebuild and restart the compose stack + +**Step 2: Document the VPS layout** + +Explain: +- where the repo lives +- where the `.env` file lives +- which service is public +- how to restart manually if needed + +### Task 4: Add the deploy workflow + +**Files:** +- Create: `.github/workflows/deploy.yml` + +**Step 1: Write the deploy workflow** + +Trigger only on pushes to `main`. + +The workflow should: +- run after CI passes +- SSH into the VPS +- execute `deploy/deploy.sh` + +**Step 2: Document required secrets** + +Add notes for: +- `VPS_HOST` +- `VPS_USER` +- `VPS_SSH_KEY` +- `VPS_PORT` if non-standard + +### Task 5: Verify and harden + +**Files:** +- Modify: only files touched above if fixes are needed + +**Step 1: Run repo checks** + +Run: `bun run lint && bun run type-check && bun run test` +Expected: PASS + +**Step 2: Confirm the workflows are syntactically valid** + +Expected behavior: +- GitHub Actions YAML parses cleanly +- compose file references the right build contexts and env vars + diff --git a/docs/plans/2026-07-03-streams-canonical-state.md b/docs/plans/2026-07-03-streams-canonical-state.md new file mode 100644 index 0000000..763cbf6 --- /dev/null +++ b/docs/plans/2026-07-03-streams-canonical-state.md @@ -0,0 +1,222 @@ +# Streams Canonical State Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make `streams` the fast-read canonical snapshot for both Flow and Lockup by storing current type, timing, status, readable payload, and remaining balance directly on the table. + +**Architecture:** Keep the event tables as the source of truth for chain history, but move the current read model onto `streams`. Creation handlers initialize the snapshot from `flow_created` and `lockup_created`, and lifecycle/activity handlers patch the current status and balance forward. GraphQL should read the snapshot row directly, so frontend reads stay cheap. + +**Tech Stack:** TypeScript, Bun, Drizzle ORM, PostgreSQL, Apollo GraphQL. + +--- + +### Task 1: Extend the `streams` table schema + +**Files:** +- Modify: `common/src/db/schema.ts` +- Create: `common/src/db/migrations/0007_add_stream_snapshot_fields.sql` +- Modify: `common/src/db/tests/schema.test.ts` + +**Step 1: Write the failing test** + +Add schema expectations for the new `streams` columns: +- `stream_type` +- `start_time` +- `end_time` +- `status` +- `remaining_amount` +- `payload_raw` +- `payload_readable` + +**Step 2: Run the schema test to verify it fails** + +Run: `bun test common/src/db/tests/schema.test.ts` +Expected: FAIL because the columns do not exist yet. + +**Step 3: Write the migration and schema change** + +Add the columns to `streams`: +- `stream_type text not null` +- `start_time bigint null` +- `end_time bigint null` +- `status text not null` +- `remaining_amount text not null default '0'` +- `payload_raw text not null default '{}'` +- `payload_readable text not null default '{}'` + +**Step 4: Run the schema test to verify it passes** + +Run: `bun test common/src/db/tests/schema.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add common/src/db/schema.ts common/src/db/migrations/0007_add_stream_snapshot_fields.sql common/src/db/tests/schema.test.ts +git commit -m "feat: add canonical stream snapshot fields" +``` + +### Task 2: Persist canonical stream snapshots on creation + +**Files:** +- Modify: `streams/src/streamRecords/types.ts` +- Modify: `streams/src/streamRecords/postgresStorage.ts` +- Modify: `streams/src/handlers/flowCreated.ts` +- Create: `streams/src/handlers/lockupCreated.ts` +- Modify: `streams/src/handlers/index.ts` +- Modify: `streams/src/handlers/tests/flowCreated.test.ts` +- Create: `streams/src/handlers/tests/lockupCreated.test.ts` +- Modify: `streams/src/streamRecords/tests/postgresStorage.test.ts` + +**Step 1: Write the failing tests** + +Cover these behaviors: +- `flow_created` inserts a `streams` row with `stream_type = flow` +- `lockup_created` inserts a `streams` row with `stream_type = lockup` +- both handlers write `payload_raw` and `payload_readable` +- lockup creation stores `start_time`, `end_time`, `status`, and `remaining_amount` + +**Step 2: Run the handler and storage tests to verify they fail** + +Run: +`bun test streams/src/handlers/tests/flowCreated.test.ts streams/src/handlers/tests/lockupCreated.test.ts streams/src/streamRecords/tests/postgresStorage.test.ts` +Expected: FAIL because the new fields and writes are not implemented yet. + +**Step 3: Write the minimal implementation** + +Update stream storage so it can upsert a full snapshot row, not just insert the old flow-only shape. +Add a dedicated `lockupCreated` handler that writes the canonical stream snapshot and also preserves the existing lockup lifecycle event write path. +Map `flow_created` and `lockup_created` into the snapshot flow in `handlers/index.ts`. + +**Step 4: Run the tests to verify they pass** + +Run the same test command. +Expected: PASS. + +**Step 5: Commit** + +```bash +git add streams/src/streamRecords/types.ts streams/src/streamRecords/postgresStorage.ts streams/src/handlers/flowCreated.ts streams/src/handlers/lockupCreated.ts streams/src/handlers/index.ts streams/src/handlers/tests/flowCreated.test.ts streams/src/handlers/tests/lockupCreated.test.ts streams/src/streamRecords/tests/postgresStorage.test.ts +git commit -m "feat: snapshot lockup and flow streams on creation" +``` + +### Task 3: Update canonical stream state on activity events + +**Files:** +- Modify: `streams/src/flowEvents/flowLifecycle.ts` +- Modify: `streams/src/adjustments/flowAdjusted.ts` +- Modify: `streams/src/deposits/flowDeposit.ts` +- Modify: `streams/src/withdrawals/flowWithdraw.ts` +- Modify: `streams/src/lockupEvents/lockupLifecycle.ts` +- Modify: `streams/src/streamRecords/postgresStorage.ts` +- Modify: `streams/src/flowEvents/tests/flowLifecycle.test.ts` +- Modify: `streams/src/adjustments/tests/flowAdjusted.test.ts` +- Modify: `streams/src/deposits/tests/flowDeposit.test.ts` +- Modify: `streams/src/withdrawals/tests/flowWithdraw.test.ts` +- Modify: `streams/src/lockupEvents/tests/lockupLifecycle.test.ts` + +**Step 1: Write the failing tests** + +Add assertions that the snapshot row gets patched for: +- `status` +- `remaining_amount` + +Keep the event history tables unchanged. + +**Step 2: Run the tests to verify they fail** + +Run: +`bun test streams/src/flowEvents/tests/flowLifecycle.test.ts streams/src/adjustments/tests/flowAdjusted.test.ts streams/src/deposits/tests/flowDeposit.test.ts streams/src/withdrawals/tests/flowWithdraw.test.ts streams/src/lockupEvents/tests/lockupLifecycle.test.ts` +Expected: FAIL because snapshot updates are not implemented yet. + +**Step 3: Write the minimal implementation** + +Add snapshot update methods to stream storage. +Update the handlers so: +- Flow pause/restart/refund/void mutate `status` +- Flow deposits/withdrawals/adjustments mutate `remaining_amount` and related snapshot state +- Lockup withdraw/cancel/renounce mutate `status` and `remaining_amount` where applicable + +**Step 4: Run the tests to verify they pass** + +Run the same test command. +Expected: PASS. + +**Step 5: Commit** + +```bash +git add streams/src/flowEvents/flowLifecycle.ts streams/src/adjustments/flowAdjusted.ts streams/src/deposits/flowDeposit.ts streams/src/withdrawals/flowWithdraw.ts streams/src/lockupEvents/lockupLifecycle.ts streams/src/streamRecords/postgresStorage.ts streams/src/flowEvents/tests/flowLifecycle.test.ts streams/src/adjustments/tests/flowAdjusted.test.ts streams/src/deposits/tests/flowDeposit.test.ts streams/src/withdrawals/tests/flowWithdraw.test.ts streams/src/lockupEvents/tests/lockupLifecycle.test.ts +git commit -m "feat: keep canonical stream state current" +``` + +### Task 4: Expose the new snapshot fields through GraphQL + +**Files:** +- Modify: `api/src/types.ts` +- Modify: `api/src/schema.ts` +- Modify: `api/src/resolvers/streams/index.ts` +- Modify: `api/src/tests/paginationQueries.test.ts` +- Modify: `api/src/tests/lifecycleQueries.test.ts` + +**Step 1: Write the failing API test** + +Query `v1.stream` and `v1.streams` for: +- `streamType` +- `startTime` +- `endTime` +- `status` +- `remainingAmount` +- `payloadRaw` +- `payloadReadable` + +**Step 2: Run the API test to verify it fails** + +Run: `bun test api/src/tests/lifecycleQueries.test.ts api/src/tests/paginationQueries.test.ts` +Expected: FAIL because the GraphQL schema and mappers do not expose the new fields yet. + +**Step 3: Write the minimal implementation** + +Add the fields to the GraphQL schema and map them from the `streams` table. + +**Step 4: Run the API tests to verify they pass** + +Run the same command. +Expected: PASS. + +**Step 5: Commit** + +```bash +git add api/src/types.ts api/src/schema.ts api/src/resolvers/streams/index.ts api/src/resolvers/streams.ts api/src/tests/paginationQueries.test.ts api/src/tests/lifecycleQueries.test.ts +git commit -m "feat: expose canonical stream snapshot fields in graphql" +``` + +### Task 5: Update docs and verify the full stack + +**Files:** +- Modify: `README.md` +- Modify: `docs/stream-indexer.md` +- Modify: `docs/plans/2026-07-03-streams-canonical-state.md` if the implementation diverges + +**Step 1: Update the docs** + +Explain: +- `streams` is the canonical fast-read snapshot +- event tables remain the history/audit trail +- `payload_raw` and `payload_readable` live on the canonical snapshot as well as the raw event tables where needed +- `remaining_amount` is persisted for fast reads + +**Step 2: Run the full checks** + +Run: +`bun run lint` +`bun run type-check` +`bun run test` + +Expected: PASS. + +**Step 3: Commit** + +```bash +git add README.md docs/stream-indexer.md +git commit -m "docs: explain canonical stream snapshot model" +``` diff --git a/docs/stream-indexer.md b/docs/stream-indexer.md index 977391e..d446b8c 100644 --- a/docs/stream-indexer.md +++ b/docs/stream-indexer.md @@ -68,6 +68,20 @@ It currently exposes: - `v1.flowEvents(streamId: ...)` - returns Flow lifecycle history - `v1.lockupEvents(streamId: ...)` - returns Lockup lifecycle history +The `Stream` GraphQL type includes the canonical snapshot fields: + +- `streamType` +- `startTime` +- `endTime` +- `startTimeReadable` +- `endTimeReadable` +- `status` +- `remainingAmount` +- `snapshotTime` +- `snapshotTimeReadable` +- `payloadRaw` +- `payloadReadable` + Its job is deliberately small: - translate GraphQL queries into Postgres reads @@ -76,6 +90,16 @@ Its job is deliberately small: New API work should go under `v1`. +## CI + +Pull requests run the repository checks in GitHub Actions: + +- `bun run lint` +- `bun run type-check` +- `bun run test` + +Operational deployment details are intentionally omitted here. + ### Implemented Event Paths #### `flow_created` @@ -99,6 +123,9 @@ Persisted row: - token - rate per second - snapshot time +- readable snapshot timestamps +- current snapshot state +- raw and readable payloads #### `flow_deposit` @@ -162,6 +189,7 @@ Persisted row: - old rate - new rate - payload JSON +- payload readable JSON - ledger / tx / event identity ## Idempotency @@ -184,9 +212,21 @@ Shared tables in `common`: - `flow_withdrawals` - `flow_adjustments` +The `streams` table is the canonical snapshot row for both Flow and Lockup creation events. It stores: + +- stream identity +- stream type +- sender, recipient, and token +- start and end timestamps, both raw and readable +- snapshot timestamp, both raw and readable +- current status +- current remaining amount +- raw payload +- readable payload + ## Runtime Composition -`streams/src/main.ts` loads config, creates the database pool, builds the app, and runs one indexing pass. +`streams/src/main.ts` loads config, creates the database pool, builds the app, and starts the continuous indexing loop. This is the current boot path: diff --git a/streams/Dockerfile b/streams/Dockerfile new file mode 100644 index 0000000..5004e4f --- /dev/null +++ b/streams/Dockerfile @@ -0,0 +1,11 @@ +FROM oven/bun:1.3.8 + +WORKDIR /app + +COPY . . + +RUN bun install --frozen-lockfile + +WORKDIR /app/streams + +CMD ["bun", "run", "start"] diff --git a/streams/package.json b/streams/package.json index e973e10..93bc085 100644 --- a/streams/package.json +++ b/streams/package.json @@ -11,6 +11,7 @@ "codegen": "bun --print \"'streams: no codegen configured yet'\"", "dev": "bun --watch src/main.ts", "lint": "biome check .", + "start": "bun src/main.ts", "test": "vitest run src", "type-check": "tsc -p tsconfig.json --noEmit" }, diff --git a/streams/src/adjustments/flowAdjusted.ts b/streams/src/adjustments/flowAdjusted.ts index 977194a..dd8baed 100644 --- a/streams/src/adjustments/flowAdjusted.ts +++ b/streams/src/adjustments/flowAdjusted.ts @@ -1,19 +1,29 @@ +import { + createFlowAdjustedReadablePayload, + serializeReadablePayload, +} from "../events/readablePayload.js"; import { createFlowLifecycleHandler } from "../flowEvents/flowLifecycle.js"; import type { FlowLifecycleStorage } from "../flowEvents/types.js"; import type { StreamIndexerEvent } from "../indexer/types.js"; +import { updateStreamSnapshot } from "../streamRecords/snapshot.js"; +import type { StreamStorage } from "../streamRecords/types.js"; import type { FlowAdjustmentStorage } from "./types.js"; export function createFlowAdjustedHandler( adjustmentStorage: FlowAdjustmentStorage, flowLifecycleStorage: FlowLifecycleStorage, + streamStorage: StreamStorage, ) { - const flowLifecycleHandler = createFlowLifecycleHandler(flowLifecycleStorage); + const flowLifecycleHandler = createFlowLifecycleHandler(flowLifecycleStorage, streamStorage); return async (event: StreamIndexerEvent) => { - await Promise.all([ - adjustmentStorage.insert(mapFlowAdjustmentEvent(event)), - flowLifecycleHandler(event), - ]); + const adjustment = mapFlowAdjustmentEvent(event); + + await Promise.all([adjustmentStorage.insert(adjustment), flowLifecycleHandler(event)]); + + await updateStreamSnapshot(streamStorage, `${adjustment.contractId}:${adjustment.streamId}`, { + ratePerSecond: adjustment.newRate, + }); }; } @@ -29,6 +39,7 @@ function mapFlowAdjustmentEvent(event: StreamIndexerEvent) { oldRate: toAmountString(oldRate, "old_rate"), newRate: toAmountString(newRate, "new_rate"), payload: serializePayload(event.data), + payloadReadable: serializeReadablePayload(createFlowAdjustedReadablePayload(event)), ledgerNumber: event.ledgerNumber, transactionHash: event.transactionHash, eventIndex: event.eventIndex, diff --git a/streams/src/adjustments/postgresStorage.ts b/streams/src/adjustments/postgresStorage.ts index 18cf800..84c178b 100644 --- a/streams/src/adjustments/postgresStorage.ts +++ b/streams/src/adjustments/postgresStorage.ts @@ -7,7 +7,7 @@ import type { } from "./types.js"; const flowAdjustmentColumns = - "id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index, created_at"; + "id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at"; export function createPostgresFlowAdjustmentStorage( client: FlowAdjustmentSqlClient, @@ -25,7 +25,7 @@ export function createPostgresFlowAdjustmentStorage( findById, async insert(input) { const result = await client.query( - `insert into flow_adjustments (id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) on conflict (id) do nothing returning ${flowAdjustmentColumns}`, + `insert into flow_adjustments (id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) on conflict (id) do nothing returning ${flowAdjustmentColumns}`, [ input.id, input.contractId, @@ -34,6 +34,7 @@ export function createPostgresFlowAdjustmentStorage( input.oldRate, input.newRate, input.payload, + input.payloadReadable, input.ledgerNumber.toString(), input.transactionHash, input.eventIndex, @@ -72,6 +73,7 @@ function mapFlowAdjustmentRow( oldRate: row.old_rate, newRate: row.new_rate, payload: row.payload, + payloadReadable: row.payload_readable, ledgerNumber: BigInt(row.ledger_number), transactionHash: row.transaction_hash, eventIndex: row.event_index, diff --git a/streams/src/adjustments/tests/flowAdjusted.test.ts b/streams/src/adjustments/tests/flowAdjusted.test.ts index 19ff1dc..a4c10fb 100644 --- a/streams/src/adjustments/tests/flowAdjusted.test.ts +++ b/streams/src/adjustments/tests/flowAdjusted.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import type { FlowLifecycleStorage } from "../../flowEvents/types.js"; import type { StreamIndexerEvent } from "../../indexer/types.js"; +import type { StreamStorage } from "../../streamRecords/types.js"; import { createFlowAdjustedHandler } from "../flowAdjusted.js"; import type { FlowAdjustmentStorage } from "../types.js"; @@ -9,7 +10,12 @@ describe("flow adjustment handler", () => { test("writes a flow adjustment record and lifecycle record from the event payload", async () => { const adjustmentStorage = createRecordingFlowAdjustmentStorage(); const flowLifecycleStorage = createRecordingFlowLifecycleStorage(); - const handler = createFlowAdjustedHandler(adjustmentStorage, flowLifecycleStorage); + const streamStorage = createRecordingStreamStorage(); + const handler = createFlowAdjustedHandler( + adjustmentStorage, + flowLifecycleStorage, + streamStorage, + ); await expect( handler({ @@ -32,6 +38,11 @@ describe("flow adjustment handler", () => { oldRate: "1000", newRate: "1200", payload: serializePayload([1234n, 1000n, 1200n]), + payloadReadable: serializePayload({ + total_debt: "1234", + old_rate: "1000", + new_rate: "1200", + }), ledgerNumber: 103n, transactionHash: "tx-c", eventIndex: 7, @@ -45,11 +56,41 @@ describe("flow adjustment handler", () => { streamId: 7n, eventName: "flow_adjusted", payload: serializePayload([1234n, 1000n, 1200n]), + payloadReadable: serializePayload({ + total_debt: "1234", + old_rate: "1000", + new_rate: "1200", + }), ledgerNumber: 103n, transactionHash: "tx-c", eventIndex: 7, }, ]); + + expect(streamStorage.updatedRecords).toEqual([ + { + id: "flow-contract:7", + contractId: "flow-contract", + streamId: 7n, + streamType: "flow", + sender: "sender", + recipient: "recipient", + token: "token", + ratePerSecond: "1200", + startTime: null, + endTime: null, + startTimeReadable: null, + endTimeReadable: null, + status: "active", + remainingAmount: "500", + snapshotTime: 123n, + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", + payloadRaw: "{}", + payloadReadable: "{}", + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }, + ]); }); }); @@ -93,6 +134,54 @@ function createRecordingFlowLifecycleStorage(): FlowLifecycleStorage & { }; } +function createRecordingStreamStorage(): StreamStorage & { + updatedRecords: unknown[]; +} { + const updatedRecords: unknown[] = []; + + return { + updatedRecords, + async findById() { + return { + id: "flow-contract:7", + contractId: "flow-contract", + streamId: 7n, + streamType: "flow", + sender: "sender", + recipient: "recipient", + token: "token", + ratePerSecond: "1000", + startTime: null, + endTime: null, + startTimeReadable: null, + endTimeReadable: null, + status: "active", + remainingAmount: "500", + snapshotTime: 123n, + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", + payloadRaw: "{}", + payloadReadable: "{}", + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + async insert(input) { + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + async update(input) { + updatedRecords.push(input); + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + }; +} + function serializePayload(value: unknown): string { return JSON.stringify(value, (_, current) => typeof current === "bigint" ? current.toString() : current, diff --git a/streams/src/adjustments/tests/postgresStorage.test.ts b/streams/src/adjustments/tests/postgresStorage.test.ts index 957fb58..29cfeb2 100644 --- a/streams/src/adjustments/tests/postgresStorage.test.ts +++ b/streams/src/adjustments/tests/postgresStorage.test.ts @@ -15,6 +15,7 @@ describe("flow adjustment postgres storage", () => { old_rate: "1000", new_rate: "1200", payload: "[1234,1000,1200]", + payload_readable: '{"total_debt":"1234","old_rate":"1000","new_rate":"1200"}', ledger_number: "103", transaction_hash: "tx-c", event_index: 7, @@ -33,6 +34,7 @@ describe("flow adjustment postgres storage", () => { oldRate: "1000", newRate: "1200", payload: "[1234,1000,1200]", + payloadReadable: '{"total_debt":"1234","old_rate":"1000","new_rate":"1200"}', ledgerNumber: 103n, transactionHash: "tx-c", eventIndex: 7, @@ -46,6 +48,7 @@ describe("flow adjustment postgres storage", () => { oldRate: "1000", newRate: "1200", payload: "[1234,1000,1200]", + payloadReadable: '{"total_debt":"1234","old_rate":"1000","new_rate":"1200"}', ledgerNumber: 103n, transactionHash: "tx-c", eventIndex: 7, @@ -54,7 +57,7 @@ describe("flow adjustment postgres storage", () => { expect(client.queries).toEqual([ { - text: "insert into flow_adjustments (id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) on conflict (id) do nothing returning id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index, created_at", + text: "insert into flow_adjustments (id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) on conflict (id) do nothing returning id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at", values: [ "flow-contract:103:tx-c:7", "flow-contract", @@ -63,6 +66,7 @@ describe("flow adjustment postgres storage", () => { "1000", "1200", "[1234,1000,1200]", + '{"total_debt":"1234","old_rate":"1000","new_rate":"1200"}', "103", "tx-c", 7, @@ -84,6 +88,7 @@ describe("flow adjustment postgres storage", () => { old_rate: "1000", new_rate: "1200", payload: "[1234,1000,1200]", + payload_readable: '{"total_debt":"1234","old_rate":"1000","new_rate":"1200"}', ledger_number: "103", transaction_hash: "tx-c", event_index: 7, @@ -102,6 +107,7 @@ describe("flow adjustment postgres storage", () => { oldRate: "1000", newRate: "1200", payload: "[1234,1000,1200]", + payloadReadable: '{"total_debt":"1234","old_rate":"1000","new_rate":"1200"}', ledgerNumber: 103n, transactionHash: "tx-c", eventIndex: 7, @@ -110,7 +116,7 @@ describe("flow adjustment postgres storage", () => { expect(result.id).toBe("flow-contract:103:tx-c:7"); expect(client.queries).toEqual([ { - text: "insert into flow_adjustments (id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) on conflict (id) do nothing returning id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index, created_at", + text: "insert into flow_adjustments (id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) on conflict (id) do nothing returning id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at", values: [ "flow-contract:103:tx-c:7", "flow-contract", @@ -119,13 +125,14 @@ describe("flow adjustment postgres storage", () => { "1000", "1200", "[1234,1000,1200]", + '{"total_debt":"1234","old_rate":"1000","new_rate":"1200"}', "103", "tx-c", 7, ], }, { - text: "select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, ledger_number, transaction_hash, event_index, created_at from flow_adjustments where id = $1 limit 1", + text: "select id, contract_id, stream_id, total_debt, old_rate, new_rate, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at from flow_adjustments where id = $1 limit 1", values: ["flow-contract:103:tx-c:7"], }, ]); diff --git a/streams/src/adjustments/types.ts b/streams/src/adjustments/types.ts index e69de64..daceb35 100644 --- a/streams/src/adjustments/types.ts +++ b/streams/src/adjustments/types.ts @@ -8,6 +8,7 @@ export type FlowAdjustmentEntity = { oldRate: string; newRate: string; payload: string; + payloadReadable: string; ledgerNumber: bigint; transactionHash: string; eventIndex: number; @@ -22,6 +23,7 @@ export type FlowAdjustmentInput = { oldRate: string; newRate: string; payload: string; + payloadReadable: string; ledgerNumber: bigint; transactionHash: string; eventIndex: number; @@ -46,6 +48,7 @@ export type FlowAdjustmentRow = { old_rate: string; new_rate: string; payload: string; + payload_readable: string; ledger_number: string | number | bigint; transaction_hash: string; event_index: number; diff --git a/streams/src/app/index.ts b/streams/src/app/index.ts index 443657d..3f621f3 100644 --- a/streams/src/app/index.ts +++ b/streams/src/app/index.ts @@ -1,4 +1,5 @@ import { + type CursorRepository, createCursorRepository, createIndexedEventRepository, createPostgresCursorStorage, @@ -21,6 +22,7 @@ import { createPostgresStreamStorage } from "../streamRecords/postgresStorage.js import { createPostgresNftTransferStorage } from "../transfers/postgresStorage.js"; import { createPostgresFlowWithdrawStorage } from "../withdrawals/postgresStorage.js"; +import type { SorobanRpcEventClient } from "../indexer/types.js"; import type { StreamContractConfig, StreamIndexerApp, StreamIndexerAppOptions } from "./types.js"; const streamCursorName = "streams"; @@ -80,18 +82,58 @@ export function createStreamIndexerApp(options: StreamIndexerAppOptions): Stream return { async runOnce() { + const startLedger = await resolveStartLedger(options.config.startLedger, sorobanClient); + await ensureStreamCursorFloor( + cursorRepository, + streamCursorName, + startLedger, + options.config.startLedger, + ); + return runStreamIndexerBatch({ eventSource, cursorRepository, indexedEventRepository, handlers, cursorName: streamCursorName, - startLedger: BigInt(options.config.startLedger ?? 0), + startLedger, }); }, }; } +async function ensureStreamCursorFloor( + cursorRepository: CursorRepository, + cursorName: string, + startLedger: bigint, + explicitStartLedger: number | undefined, +): Promise { + if (explicitStartLedger !== undefined) { + return; + } + + const cursor = await cursorRepository.getCursor(cursorName); + + if (cursor === undefined || cursor.lastProcessedLedger >= startLedger) { + return; + } + + await cursorRepository.advanceCursor(cursorName, startLedger); +} + +async function resolveStartLedger( + startLedger: number | undefined, + sorobanClient: Pick, +): Promise { + if (startLedger !== undefined) { + return BigInt(startLedger); + } + + const latestLedger = BigInt(await sorobanClient.getLatestLedger()); + + return latestLedger > 0n ? latestLedger - 1n : 0n; +} + function requireStreamContractConfig( config: StreamIndexerAppOptions["config"], ): StreamContractConfig { diff --git a/streams/src/app/tests/index.test.ts b/streams/src/app/tests/index.test.ts index 78cfdc0..d3c399e 100644 --- a/streams/src/app/tests/index.test.ts +++ b/streams/src/app/tests/index.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import type { RuntimeConfig } from "@fundable-indexer/common"; +import { streamEventCatalog } from "../../events/catalog.js"; import type { SorobanRpcEventClient, SorobanRpcGetEventsResponse } from "../../indexer/types.js"; import { createStreamIndexerApp } from "../index.js"; import type { StreamIndexerSqlClient } from "../types.js"; @@ -17,6 +18,287 @@ describe("stream indexer app composition", () => { ).toThrow("Missing stream contract IDs: FLOW_CONTRACT_ID"); }); + test("uses the default stream start ledger when none is configured", async () => { + const sqlClient = createRecordingSqlClient([ + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274673", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274673", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274674", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274674", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274674", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274674", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274674", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274674", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274674", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274674", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274674", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + ]); + const sorobanClient = createRecordingRpcClient({ + latestLedger: 3274674n, + events: [], + }); + const app = createStreamIndexerApp({ + config: createRuntimeConfig({ startLedger: undefined }), + sqlClient, + sorobanClient, + handlers: {}, + }); + + await expect(app.runOnce()).resolves.toEqual({ + fetchedEvents: 0, + processedEvents: 0, + skippedEvents: 0, + lastProcessedLedger: 3274674n, + }); + expect(sorobanClient.requests).toEqual([ + { + startLedger: 3274674, + filters: createExpectedFilters( + ["flow-contract", "lockup-contract", "stream-nft-contract"], + [ + "flow_created", + "flow_deposit", + "flow_withdraw", + "flow_adjusted", + "flow_paused", + "flow_restarted", + "flow_refunded", + "flow_voided", + "lockup_created", + "lockup_withdraw", + "lockup_canceled", + "lockup_renounced", + "transfer", + ], + ), + }, + ]); + expect(sqlClient.queries).toEqual([ + { + text: "select name, last_processed_ledger, created_at, updated_at from indexer_cursors where name = $1 limit 1", + values: ["streams"], + }, + { + text: "select name, last_processed_ledger, created_at, updated_at from indexer_cursors where name = $1 limit 1", + values: ["streams"], + }, + { + text: "select name, last_processed_ledger, created_at, updated_at from indexer_cursors where name = $1 limit 1", + values: ["streams"], + }, + { + text: "update indexer_cursors set last_processed_ledger = $2, updated_at = now() where name = $1 returning name, last_processed_ledger, created_at, updated_at", + values: ["streams", "3274674"], + }, + ]); + }); + + test("bumps a stale default stream cursor to the default start ledger", async () => { + const sqlClient = createRecordingSqlClient([ + { + rows: [ + { + name: "streams", + last_processed_ledger: "0", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "0", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274673", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274673", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274674", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + { + rows: [ + { + name: "streams", + last_processed_ledger: "3274674", + created_at: new Date("2026-06-26T00:00:00.000Z"), + updated_at: new Date("2026-06-26T00:00:00.000Z"), + }, + ], + }, + ]); + const sorobanClient = createRecordingRpcClient({ + latestLedger: 3274674n, + events: [], + }); + const app = createStreamIndexerApp({ + config: createRuntimeConfig({ startLedger: undefined }), + sqlClient, + sorobanClient, + handlers: {}, + }); + + await expect(app.runOnce()).resolves.toEqual({ + fetchedEvents: 0, + processedEvents: 0, + skippedEvents: 0, + lastProcessedLedger: 3274674n, + }); + expect(sqlClient.queries).toEqual([ + { + text: "select name, last_processed_ledger, created_at, updated_at from indexer_cursors where name = $1 limit 1", + values: ["streams"], + }, + { + text: "select name, last_processed_ledger, created_at, updated_at from indexer_cursors where name = $1 limit 1", + values: ["streams"], + }, + { + text: "update indexer_cursors set last_processed_ledger = $2, updated_at = now() where name = $1 returning name, last_processed_ledger, created_at, updated_at", + values: ["streams", "3274673"], + }, + { + text: "select name, last_processed_ledger, created_at, updated_at from indexer_cursors where name = $1 limit 1", + values: ["streams"], + }, + { + text: "select name, last_processed_ledger, created_at, updated_at from indexer_cursors where name = $1 limit 1", + values: ["streams"], + }, + { + text: "update indexer_cursors set last_processed_ledger = $2, updated_at = now() where name = $1 returning name, last_processed_ledger, created_at, updated_at", + values: ["streams", "3274674"], + }, + ]); + }); + test("runs one batch with DB-backed cursor and indexed-event repositories", async () => { const sqlClient = createRecordingSqlClient([ { @@ -99,27 +381,24 @@ describe("stream indexer app composition", () => { expect(sorobanClient.requests).toEqual([ { startLedger: 101, - filters: [ - { - type: "contract", - contractIds: ["flow-contract", "lockup-contract", "stream-nft-contract"], - topics: [ - ["flow_created"], - ["flow_deposit"], - ["flow_withdraw"], - ["flow_adjusted"], - ["flow_paused"], - ["flow_restarted"], - ["flow_refunded"], - ["flow_voided"], - ["lockup_created"], - ["lockup_withdraw"], - ["lockup_canceled"], - ["lockup_renounced"], - ["transfer"], - ], - }, - ], + filters: createExpectedFilters( + ["flow-contract", "lockup-contract", "stream-nft-contract"], + [ + "flow_created", + "flow_deposit", + "flow_withdraw", + "flow_adjusted", + "flow_paused", + "flow_restarted", + "flow_refunded", + "flow_voided", + "lockup_created", + "lockup_withdraw", + "lockup_canceled", + "lockup_renounced", + "transfer", + ], + ), pagination: { limit: 25 }, }, ]); @@ -133,16 +412,28 @@ describe("stream indexer app composition", () => { values: ["flow-contract:101:tx-a:4"], }, { - text: "insert into streams (id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time) values ($1, $2, $3, $4, $5, $6, $7, $8) on conflict (id) do nothing returning id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at", + text: `insert into streams (id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + on conflict (id) do nothing returning id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable, created_at, updated_at`, values: [ "flow-contract:7", "flow-contract", "7", + "flow", "sender", "recipient", "token", "1", + null, + null, + null, + null, + "active", + "0", "123", + "1970-01-01T00:02:03.000Z", + '["sender","recipient","token","1","123"]', + '{"sender":"sender","recipient":"recipient","token":"token","rate_per_second":"1","snapshot_time":"123"}', ], }, { @@ -204,6 +495,9 @@ function createRecordingRpcClient(response: SorobanRpcGetEventsResponse): Soroba return { requests, + async getLatestLedger() { + return response.latestLedger; + }, async getEvents(request) { requests.push(request); return response; @@ -216,12 +510,55 @@ function createStreamRow() { id: "flow-contract:7", contract_id: "flow-contract", stream_id: "7", + stream_type: "flow", sender: "sender", recipient: "recipient", token: "token", rate_per_second: "1", + start_time: null, + end_time: null, + start_time_readable: null, + end_time_readable: null, + status: "active", + remaining_amount: "0", snapshot_time: "123", + snapshot_time_readable: "1970-01-01T00:02:03.000Z", + payload_raw: "{}", + payload_readable: "{}", created_at: new Date("2026-06-26T00:00:00.000Z"), updated_at: new Date("2026-06-26T00:00:00.000Z"), }; } + +function encodeSymbolTopic(value: string): string { + return value; +} + +function createExpectedFilters(contractIds: readonly string[], eventNames: readonly string[]) { + return chunk(eventNames, 5).map((chunkedEventNames) => ({ + type: "contract" as const, + contractIds: [...contractIds], + topics: chunkedEventNames.map((eventName) => buildTopicPath(eventName)), + })); +} + +function buildTopicPath(eventName: string): string[] { + const topicCount = streamEventCatalog[eventName as keyof typeof streamEventCatalog].topics.length; + const topicPath = [encodeSymbolTopic(eventName)]; + + for (let index = 1; index < topicCount; index += 1) { + topicPath.push("*"); + } + + return topicPath; +} + +function chunk(items: readonly T[], size: number): T[][] { + const chunks: T[][] = []; + + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + + return chunks; +} diff --git a/streams/src/deposits/flowDeposit.ts b/streams/src/deposits/flowDeposit.ts index be522e4..591bc87 100644 --- a/streams/src/deposits/flowDeposit.ts +++ b/streams/src/deposits/flowDeposit.ts @@ -1,9 +1,21 @@ import type { StreamIndexerEvent } from "../indexer/types.js"; +import { addStreamRemainingAmount } from "../streamRecords/snapshot.js"; +import type { StreamStorage } from "../streamRecords/types.js"; import type { FlowDepositStorage } from "./types.js"; -export function createFlowDepositHandler(storage: FlowDepositStorage) { +export function createFlowDepositHandler( + storage: FlowDepositStorage, + streamStorage: StreamStorage, +) { return async (event: StreamIndexerEvent) => { - await storage.insert(mapFlowDepositEvent(event)); + const deposit = mapFlowDepositEvent(event); + await storage.insert(deposit); + + await addStreamRemainingAmount( + streamStorage, + `${deposit.contractId}:${deposit.streamId}`, + deposit.amount, + ); }; } diff --git a/streams/src/deposits/tests/flowDeposit.test.ts b/streams/src/deposits/tests/flowDeposit.test.ts index d1b9572..099d001 100644 --- a/streams/src/deposits/tests/flowDeposit.test.ts +++ b/streams/src/deposits/tests/flowDeposit.test.ts @@ -1,13 +1,15 @@ import { describe, expect, test } from "vitest"; import type { StreamIndexerEvent } from "../../indexer/types.js"; +import type { StreamStorage } from "../../streamRecords/types.js"; import { createFlowDepositHandler } from "../flowDeposit.js"; import type { FlowDepositStorage } from "../types.js"; describe("flow deposit handler", () => { test("writes a flow deposit record from the flow_deposit event payload", async () => { const storage = createRecordingFlowDepositStorage(); - const handler = createFlowDepositHandler(storage); + const streamStorage = createRecordingStreamStorage(); + const handler = createFlowDepositHandler(storage, streamStorage); await expect(handler(createFlowDepositEvent())).resolves.toBeUndefined(); @@ -23,6 +25,31 @@ describe("flow deposit handler", () => { eventIndex: 4, }, ]); + + expect(streamStorage.updatedRecords).toEqual([ + { + id: "flow-contract:7", + contractId: "flow-contract", + streamId: 7n, + streamType: "flow", + sender: "sender", + recipient: "recipient", + token: "token", + ratePerSecond: "1000", + startTime: null, + endTime: null, + startTimeReadable: null, + endTimeReadable: null, + status: "active", + remainingAmount: "1250", + snapshotTime: 123n, + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", + payloadRaw: "{}", + payloadReadable: "{}", + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }, + ]); }); }); @@ -57,3 +84,52 @@ function createRecordingFlowDepositStorage(): FlowDepositStorage & { }, }; } + +function createRecordingStreamStorage(): StreamStorage & { + updatedRecords: unknown[]; +} { + const updatedRecords: unknown[] = []; + + return { + updatedRecords, + async findById() { + return { + id: "flow-contract:7", + contractId: "flow-contract", + streamId: 7n, + streamType: "flow", + sender: "sender", + recipient: "recipient", + token: "token", + ratePerSecond: "1000", + startTime: null, + endTime: null, + startTimeReadable: null, + endTimeReadable: null, + status: "active", + remainingAmount: "1000", + snapshotTime: 123n, + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", + payloadRaw: "{}", + payloadReadable: "{}", + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + async insert(input) { + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + async update(input) { + updatedRecords.push(input); + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + }; +} diff --git a/streams/src/events/readablePayload.ts b/streams/src/events/readablePayload.ts new file mode 100644 index 0000000..536c061 --- /dev/null +++ b/streams/src/events/readablePayload.ts @@ -0,0 +1,253 @@ +import type { StreamIndexerEvent } from "../indexer/types.js"; + +type ReadablePayload = Record; + +export function serializePayload(value: unknown): string { + return JSON.stringify(value, (_, current) => + typeof current === "bigint" ? current.toString() : current, + ); +} + +export function serializeReadablePayload(value: ReadablePayload): string { + return JSON.stringify(value, (_, current) => + typeof current === "bigint" ? current.toString() : current, + ); +} + +export function createFlowCreatedReadablePayload(event: StreamIndexerEvent): ReadablePayload { + const [sender, recipient, token, ratePerSecond, snapshotTime] = readFlowCreatedData(event.data); + + return { + sender: toText(sender, "sender"), + recipient: toText(recipient, "recipient"), + token: toText(token, "token"), + rate_per_second: toText(ratePerSecond, "rate_per_second"), + snapshot_time: toText(snapshotTime, "snapshot_time"), + }; +} + +export function createFlowAdjustedReadablePayload(event: StreamIndexerEvent): ReadablePayload { + const [totalDebt, oldRate, newRate] = readFlowAdjustedData(event.data); + + return { + total_debt: toText(totalDebt, "total_debt"), + old_rate: toText(oldRate, "old_rate"), + new_rate: toText(newRate, "new_rate"), + }; +} + +export function createFlowLifecycleReadablePayload(event: StreamIndexerEvent): ReadablePayload { + switch (event.name) { + case "flow_adjusted": + return createFlowAdjustedReadablePayload(event); + case "flow_paused": { + const [sender, recipient, totalDebt] = readFlowPausedData(event.data); + + return { + sender: toText(sender, "sender"), + recipient: toText(recipient, "recipient"), + total_debt: toText(totalDebt, "total_debt"), + }; + } + case "flow_restarted": { + const [sender, ratePerSecond] = readFlowRestartedData(event.data); + + return { + sender: toText(sender, "sender"), + rate_per_second: toText(ratePerSecond, "rate_per_second"), + }; + } + case "flow_refunded": { + const [sender, amount] = readFlowRefundedData(event.data); + + return { + sender: toText(sender, "sender"), + amount: toText(amount, "amount"), + }; + } + case "flow_voided": { + const [sender, recipient, caller, newTotalDebt, writtenOffDebt] = readFlowVoidedData( + event.data, + ); + + return { + sender: toText(sender, "sender"), + recipient: toText(recipient, "recipient"), + caller: toText(caller, "caller"), + new_total_debt: toText(newTotalDebt, "new_total_debt"), + written_off_debt: toText(writtenOffDebt, "written_off_debt"), + }; + } + default: + throw new Error(`unsupported flow lifecycle event "${event.name}"`); + } +} + +export function createLockupLifecycleReadablePayload(event: StreamIndexerEvent): ReadablePayload { + switch (event.name) { + case "lockup_created": { + return createLockupCreatedReadablePayload(event); + } + case "lockup_withdraw": { + const [to, caller, amount] = readLockupWithdrawData(event.data); + + return { + to: toText(to, "to"), + caller: toText(caller, "caller"), + amount: toText(amount, "amount"), + }; + } + case "lockup_canceled": { + const [sender, recipient, senderAmount, recipientAmount] = readLockupCanceledData(event.data); + + return { + sender: toText(sender, "sender"), + recipient: toText(recipient, "recipient"), + sender_amount: toText(senderAmount, "sender_amount"), + recipient_amount: toText(recipientAmount, "recipient_amount"), + }; + } + case "lockup_renounced": + return {}; + default: + throw new Error(`unsupported lockup lifecycle event "${event.name}"`); + } +} + +export function createLockupCreatedReadablePayload(event: StreamIndexerEvent): ReadablePayload { + const [sender, recipient, token, totalAmount, schedule] = readLockupCreatedData(event.data); + const [startTime, endTime, cliffTime, cancelable] = readLockupScheduleData(schedule); + + return { + sender: toText(sender, "sender"), + recipient: toText(recipient, "recipient"), + token: toText(token, "token"), + total_amount: toText(totalAmount, "total_amount"), + start_time: toUnixTimestampIso(startTime, "start_time"), + end_time: toUnixTimestampIso(endTime, "end_time"), + cliff_time: normalizeCliffTime(cliffTime), + cancelable: toBoolean(cancelable, "cancelable"), + }; +} + +function readFlowAdjustedData(data: unknown): [unknown, unknown, unknown] { + if (!Array.isArray(data) || data.length < 3) { + throw new Error("flow_adjusted event payload is malformed"); + } + + return [data[0], data[1], data[2]]; +} + +function readFlowCreatedData(data: unknown): [unknown, unknown, unknown, unknown, unknown] { + if (!Array.isArray(data) || data.length < 5) { + throw new Error("flow_created event payload is malformed"); + } + + return [data[0], data[1], data[2], data[3], data[4]]; +} + +function readFlowPausedData(data: unknown): [unknown, unknown, unknown] { + if (!Array.isArray(data) || data.length < 3) { + throw new Error("flow_paused event payload is malformed"); + } + + return [data[0], data[1], data[2]]; +} + +function readFlowRestartedData(data: unknown): [unknown, unknown] { + if (!Array.isArray(data) || data.length < 2) { + throw new Error("flow_restarted event payload is malformed"); + } + + return [data[0], data[1]]; +} + +function readFlowRefundedData(data: unknown): [unknown, unknown] { + if (!Array.isArray(data) || data.length < 2) { + throw new Error("flow_refunded event payload is malformed"); + } + + return [data[0], data[1]]; +} + +function readFlowVoidedData(data: unknown): [unknown, unknown, unknown, unknown, unknown] { + if (!Array.isArray(data) || data.length < 5) { + throw new Error("flow_voided event payload is malformed"); + } + + return [data[0], data[1], data[2], data[3], data[4]]; +} + +function readLockupCreatedData(data: unknown): [unknown, unknown, unknown, unknown, unknown] { + if (!Array.isArray(data) || data.length < 5) { + throw new Error("lockup_created event payload is malformed"); + } + + return [data[0], data[1], data[2], data[3], data[4]]; +} + +function readLockupScheduleData(value: unknown): [unknown, unknown, unknown, unknown] { + if (!Array.isArray(value) || value.length < 4) { + throw new Error("lockup_created event schedule is malformed"); + } + + return [value[0], value[1], value[2], value[3]]; +} + +function readLockupWithdrawData(data: unknown): [unknown, unknown, unknown] { + if (!Array.isArray(data) || data.length < 3) { + throw new Error("lockup_withdraw event payload is malformed"); + } + + return [data[0], data[1], data[2]]; +} + +function readLockupCanceledData(data: unknown): [unknown, unknown, unknown, unknown] { + if (!Array.isArray(data) || data.length < 4) { + throw new Error("lockup_canceled event payload is malformed"); + } + + return [data[0], data[1], data[2], data[3]]; +} + +function toText(value: unknown, field: string): string { + if (typeof value === "string") return value; + + if (value === undefined || value === null) { + throw new Error(`payload is missing ${field}`); + } + + return String(value); +} + +function toBoolean(value: unknown, field: string): boolean { + if (typeof value === "boolean") return value; + + throw new Error(`payload is missing ${field}`); +} + +function toUnixTimestampIso(value: unknown, field: string): string { + const seconds = toBigInt(value, field); + return new Date(Number(seconds) * 1000).toISOString(); +} + +function normalizeCliffTime(value: unknown): string | null { + const seconds = toBigInt(value, "cliff_time"); + if (seconds === 0n) return null; + + return new Date(Number(seconds) * 1000).toISOString(); +} + +function toBigInt(value: unknown, field: string): bigint { + if (typeof value === "bigint") return value; + + if (typeof value === "number" && Number.isInteger(value)) { + return BigInt(value); + } + + if (typeof value === "string" && value.length > 0) { + return BigInt(value); + } + + throw new Error(`payload is missing ${field}`); +} diff --git a/streams/src/flowEvents/flowLifecycle.ts b/streams/src/flowEvents/flowLifecycle.ts index 700775e..1ec695a 100644 --- a/streams/src/flowEvents/flowLifecycle.ts +++ b/streams/src/flowEvents/flowLifecycle.ts @@ -1,9 +1,20 @@ +import { + createFlowLifecycleReadablePayload, + serializeReadablePayload, +} from "../events/readablePayload.js"; import type { StreamIndexerEvent } from "../indexer/types.js"; +import { updateStreamSnapshot } from "../streamRecords/snapshot.js"; +import type { StreamStorage } from "../streamRecords/types.js"; import type { FlowLifecycleStorage } from "./types.js"; -export function createFlowLifecycleHandler(storage: FlowLifecycleStorage) { +export function createFlowLifecycleHandler( + storage: FlowLifecycleStorage, + streamStorage: StreamStorage, +) { return async (event: StreamIndexerEvent) => { - await storage.insert(mapFlowLifecycleEvent(event)); + const lifecycleRecord = mapFlowLifecycleEvent(event); + await storage.insert(lifecycleRecord); + await updateStreamSnapshotForFlowLifecycle(event, streamStorage); }; } @@ -16,6 +27,7 @@ function mapFlowLifecycleEvent(event: StreamIndexerEvent) { streamId, eventName: event.name, payload: serializePayload(event.data), + payloadReadable: serializeReadablePayload(createFlowLifecycleReadablePayload(event)), ledgerNumber: event.ledgerNumber, transactionHash: event.transactionHash, eventIndex: event.eventIndex, @@ -41,3 +53,63 @@ function toBigInt(value: unknown, field: string): bigint { throw new Error(`flow lifecycle event is missing ${field}`); } + +async function updateStreamSnapshotForFlowLifecycle( + event: StreamIndexerEvent, + streamStorage: StreamStorage, +): Promise { + const streamId = toBigInt(event.topics[1], "stream_id"); + const streamRecordId = `${event.contractId}:${streamId}`; + + switch (event.name) { + case "flow_paused": + await updateStreamSnapshot(streamStorage, streamRecordId, { + status: "paused", + }); + return; + case "flow_restarted": { + const ratePerSecond = readFlowRestartedRate(event.data); + await updateStreamSnapshot(streamStorage, streamRecordId, { + status: "active", + ratePerSecond, + }); + return; + } + case "flow_refunded": + await updateStreamSnapshot(streamStorage, streamRecordId, { + status: "refunded", + remainingAmount: "0", + }); + return; + case "flow_voided": + await updateStreamSnapshot(streamStorage, streamRecordId, { + status: "voided", + remainingAmount: "0", + }); + return; + default: + return; + } +} + +function readFlowRestartedRate(data: unknown): string { + if (!Array.isArray(data) || data.length < 2) { + throw new Error("flow_restarted event payload is malformed"); + } + + return toText(data[1], "rate_per_second"); +} + +function toText(value: unknown, field: string): string { + if (typeof value === "string") return value; + + if (typeof value === "bigint" || typeof value === "number") { + return String(value); + } + + if (value === undefined || value === null) { + throw new Error(`flow lifecycle event is missing ${field}`); + } + + return String(value); +} diff --git a/streams/src/flowEvents/postgresStorage.ts b/streams/src/flowEvents/postgresStorage.ts index 317efba..742e7ca 100644 --- a/streams/src/flowEvents/postgresStorage.ts +++ b/streams/src/flowEvents/postgresStorage.ts @@ -7,7 +7,7 @@ import type { } from "./types.js"; const flowLifecycleColumns = - "id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index, created_at"; + "id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at"; export function createPostgresFlowLifecycleStorage( client: FlowLifecycleSqlClient, @@ -25,13 +25,14 @@ export function createPostgresFlowLifecycleStorage( findById, async insert(input) { const result = await client.query( - `insert into flow_events (id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index) values ($1, $2, $3, $4, $5, $6, $7, $8) on conflict (id) do nothing returning ${flowLifecycleColumns}`, + `insert into flow_events (id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index) values ($1, $2, $3, $4, $5, $6, $7, $8, $9) on conflict (id) do nothing returning ${flowLifecycleColumns}`, [ input.id, input.contractId, input.streamId.toString(), input.eventName, input.payload, + input.payloadReadable, input.ledgerNumber.toString(), input.transactionHash, input.eventIndex, @@ -66,6 +67,7 @@ function mapFlowLifecycleRow(row: FlowLifecycleRow | undefined): FlowLifecycleEn streamId: BigInt(row.stream_id), eventName: row.event_name, payload: row.payload, + payloadReadable: row.payload_readable, ledgerNumber: BigInt(row.ledger_number), transactionHash: row.transaction_hash, eventIndex: row.event_index, diff --git a/streams/src/flowEvents/tests/flowLifecycle.test.ts b/streams/src/flowEvents/tests/flowLifecycle.test.ts index a90b6c0..b431dd2 100644 --- a/streams/src/flowEvents/tests/flowLifecycle.test.ts +++ b/streams/src/flowEvents/tests/flowLifecycle.test.ts @@ -1,13 +1,15 @@ import { describe, expect, test } from "vitest"; import type { StreamIndexerEvent } from "../../indexer/types.js"; +import type { StreamStorage } from "../../streamRecords/types.js"; import { createFlowLifecycleHandler } from "../flowLifecycle.js"; import type { FlowLifecycleStorage } from "../types.js"; describe("flow lifecycle handler", () => { test("writes a flow lifecycle record from the event payload", async () => { const storage = createRecordingFlowLifecycleStorage(); - const handler = createFlowLifecycleHandler(storage); + const streamStorage = createRecordingStreamStorage(); + const handler = createFlowLifecycleHandler(storage, streamStorage); await expect( handler({ @@ -28,11 +30,18 @@ describe("flow lifecycle handler", () => { streamId: 7n, eventName: "flow_adjusted", payload: serializePayload([1234n, 1000n, 1200n]), + payloadReadable: serializePayload({ + total_debt: "1234", + old_rate: "1000", + new_rate: "1200", + }), ledgerNumber: 103n, transactionHash: "tx-c", eventIndex: 7, }, ]); + + expect(streamStorage.updatedRecords).toEqual([]); }); }); @@ -56,6 +65,34 @@ function createRecordingFlowLifecycleStorage(): FlowLifecycleStorage & { }; } +function createRecordingStreamStorage(): StreamStorage & { + updatedRecords: unknown[]; +} { + const updatedRecords: unknown[] = []; + + return { + updatedRecords, + async findById() { + return undefined; + }, + async insert(input) { + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + async update(input) { + updatedRecords.push(input); + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + }; +} + function serializePayload(value: unknown): string { return JSON.stringify(value, (_, current) => typeof current === "bigint" ? current.toString() : current, diff --git a/streams/src/flowEvents/tests/postgresStorage.test.ts b/streams/src/flowEvents/tests/postgresStorage.test.ts index 15bb2b7..669194d 100644 --- a/streams/src/flowEvents/tests/postgresStorage.test.ts +++ b/streams/src/flowEvents/tests/postgresStorage.test.ts @@ -18,6 +18,11 @@ describe("PostgreSQL flow lifecycle storage", () => { streamId: 7n, eventName: "flow_adjusted", payload: serializePayload([1234n, 1000n, 1200n]), + payloadReadable: serializePayload({ + total_debt: "1234", + old_rate: "1000", + new_rate: "1200", + }), ledgerNumber: 103n, transactionHash: "tx-c", eventIndex: 7, @@ -40,6 +45,11 @@ describe("PostgreSQL flow lifecycle storage", () => { streamId: 7n, eventName: "flow_adjusted", payload: serializePayload([1234n, 1000n, 1200n]), + payloadReadable: serializePayload({ + total_debt: "1234", + old_rate: "1000", + new_rate: "1200", + }), ledgerNumber: 103n, transactionHash: "tx-c", eventIndex: 7, @@ -58,6 +68,11 @@ function createFlowLifecycleRow() { stream_id: "7", event_name: "flow_adjusted", payload: serializePayload([1234n, 1000n, 1200n]), + payload_readable: serializePayload({ + total_debt: "1234", + old_rate: "1000", + new_rate: "1200", + }), ledger_number: "103", transaction_hash: "tx-c", event_index: 7, diff --git a/streams/src/flowEvents/types.ts b/streams/src/flowEvents/types.ts index 4adc9f9..49d73eb 100644 --- a/streams/src/flowEvents/types.ts +++ b/streams/src/flowEvents/types.ts @@ -6,6 +6,7 @@ export type FlowLifecycleEntity = { streamId: bigint; eventName: string; payload: string; + payloadReadable: string; ledgerNumber: bigint; transactionHash: string; eventIndex: number; @@ -18,6 +19,7 @@ export type FlowLifecycleInput = { streamId: bigint; eventName: string; payload: string; + payloadReadable: string; ledgerNumber: bigint; transactionHash: string; eventIndex: number; @@ -40,6 +42,7 @@ export type FlowLifecycleRow = { stream_id: string | number | bigint; event_name: string; payload: string; + payload_readable: string; ledger_number: string | number | bigint; transaction_hash: string; event_index: number; diff --git a/streams/src/handlers/flowCreated.ts b/streams/src/handlers/flowCreated.ts index 3225868..b127f78 100644 --- a/streams/src/handlers/flowCreated.ts +++ b/streams/src/handlers/flowCreated.ts @@ -1,4 +1,10 @@ +import { + createFlowCreatedReadablePayload, + serializePayload, + serializeReadablePayload, +} from "../events/readablePayload.js"; import type { StreamIndexerEvent } from "../indexer/types.js"; +import { toReadableUnixTimestamp } from "../streamRecords/time.js"; import type { StreamStorage } from "../streamRecords/types.js"; export function createFlowCreatedHandler(storage: StreamStorage) { @@ -15,15 +21,27 @@ function mapFlowCreatedEvent(event: StreamIndexerEvent) { throw new Error("flow_created event payload is malformed"); } + const snapshotTime = toBigInt(payload[4], "snapshot_time"); + return { streamId, contractId: event.contractId, + streamType: "flow", token: toText(payload[2], "token"), id: `${event.contractId}:${streamId}`, sender: toText(payload[0], "sender"), recipient: toText(payload[1], "recipient"), + startTime: null, + endTime: null, + status: "active", + remainingAmount: "0", snapshotTime: toBigInt(payload[4], "snapshot_time"), ratePerSecond: toText(payload[3], "rate_per_second"), + payloadRaw: serializePayload(event.data), + payloadReadable: serializeReadablePayload(createFlowCreatedReadablePayload(event)), + startTimeReadable: null, + endTimeReadable: null, + snapshotTimeReadable: toReadableUnixTimestamp(snapshotTime), }; } diff --git a/streams/src/handlers/index.ts b/streams/src/handlers/index.ts index 36044c6..519cac2 100644 --- a/streams/src/handlers/index.ts +++ b/streams/src/handlers/index.ts @@ -13,6 +13,7 @@ import type { NftTransferStorage } from "../transfers/types.js"; import { createFlowWithdrawHandler } from "../withdrawals/flowWithdraw.js"; import type { FlowWithdrawStorage } from "../withdrawals/types.js"; import { createFlowCreatedHandler } from "./flowCreated.js"; +import { createLockupCreatedHandler } from "./lockupCreated.js"; export function createStreamHandlers(storage: { flowAdjustmentStorage: FlowAdjustmentStorage; @@ -23,23 +24,34 @@ export function createStreamHandlers(storage: { lockupLifecycleStorage: LockupLifecycleStorage; nftTransferStorage: NftTransferStorage; }): StreamEventHandlers { - const flowLifecycleHandler = createFlowLifecycleHandler(storage.flowLifecycleStorage); - const lockupLifecycleHandler = createLockupLifecycleHandler(storage.lockupLifecycleStorage); + const flowLifecycleHandler = createFlowLifecycleHandler( + storage.flowLifecycleStorage, + storage.streamStorage, + ); + const lockupLifecycleHandler = createLockupLifecycleHandler( + storage.lockupLifecycleStorage, + storage.streamStorage, + ); const flowAdjustedHandler = createFlowAdjustedHandler( storage.flowAdjustmentStorage, storage.flowLifecycleStorage, + storage.streamStorage, + ); + const lockupCreatedHandler = createLockupCreatedHandler( + storage.streamStorage, + storage.lockupLifecycleStorage, ); return { - flow_deposit: createFlowDepositHandler(storage.flowDepositStorage), - flow_withdraw: createFlowWithdrawHandler(storage.flowWithdrawStorage), + flow_deposit: createFlowDepositHandler(storage.flowDepositStorage, storage.streamStorage), + flow_withdraw: createFlowWithdrawHandler(storage.flowWithdrawStorage, storage.streamStorage), flow_created: createFlowCreatedHandler(storage.streamStorage), flow_adjusted: flowAdjustedHandler, flow_paused: flowLifecycleHandler, flow_restarted: flowLifecycleHandler, flow_refunded: flowLifecycleHandler, flow_voided: flowLifecycleHandler, - lockup_created: lockupLifecycleHandler, + lockup_created: lockupCreatedHandler, lockup_withdraw: lockupLifecycleHandler, lockup_canceled: lockupLifecycleHandler, lockup_renounced: lockupLifecycleHandler, diff --git a/streams/src/handlers/lockupCreated.ts b/streams/src/handlers/lockupCreated.ts new file mode 100644 index 0000000..274ae00 --- /dev/null +++ b/streams/src/handlers/lockupCreated.ts @@ -0,0 +1,107 @@ +import { + createLockupCreatedReadablePayload, + serializePayload, + serializeReadablePayload, +} from "../events/readablePayload.js"; +import type { StreamIndexerEvent } from "../indexer/types.js"; +import type { LockupLifecycleStorage } from "../lockupEvents/types.js"; +import { toReadableUnixTimestamp } from "../streamRecords/time.js"; +import type { StreamStorage } from "../streamRecords/types.js"; + +export function createLockupCreatedHandler( + streamStorage: StreamStorage, + lockupLifecycleStorage: LockupLifecycleStorage, +) { + return async (event: StreamIndexerEvent) => { + const streamRecord = mapLockupCreatedStream(event); + const lockupLifecycleRecord = mapLockupCreatedEvent(event); + + await streamStorage.insert(streamRecord); + await lockupLifecycleStorage.insert(lockupLifecycleRecord); + }; +} + +function mapLockupCreatedStream(event: StreamIndexerEvent) { + const streamId = toBigInt(event.topics[1], "stream_id"); + const [sender, recipient, token, totalAmount, schedule] = readLockupCreatedData(event.data); + const [startTime, endTime] = readLockupScheduleData(schedule); + const startTimeValue = toBigInt(startTime, "start_time"); + const endTimeValue = toBigInt(endTime, "end_time"); + + return { + id: `${event.contractId}:${streamId}`, + contractId: event.contractId, + streamId, + streamType: "lockup", + sender: toText(sender, "sender"), + recipient: toText(recipient, "recipient"), + token: toText(token, "token"), + ratePerSecond: "0", + startTime: startTimeValue, + endTime: endTimeValue, + startTimeReadable: toReadableUnixTimestamp(startTimeValue), + endTimeReadable: toReadableUnixTimestamp(endTimeValue), + status: "active", + remainingAmount: toText(totalAmount, "total_amount"), + snapshotTime: startTimeValue, + snapshotTimeReadable: toReadableUnixTimestamp(startTimeValue), + payloadRaw: serializePayload(event.data), + payloadReadable: serializeReadablePayload(createLockupCreatedReadablePayload(event)), + }; +} + +function mapLockupCreatedEvent(event: StreamIndexerEvent) { + const streamId = toBigInt(event.topics[1], "stream_id"); + + return { + id: `${event.contractId}:${event.ledgerNumber}:${event.transactionHash}:${event.eventIndex}`, + contractId: event.contractId, + streamId, + eventName: event.name, + payload: serializePayload(event.data), + payloadReadable: serializeReadablePayload(createLockupCreatedReadablePayload(event)), + ledgerNumber: event.ledgerNumber, + transactionHash: event.transactionHash, + eventIndex: event.eventIndex, + }; +} + +function readLockupCreatedData(data: unknown): [unknown, unknown, unknown, unknown, unknown] { + if (!Array.isArray(data) || data.length < 5) { + throw new Error("lockup_created event payload is malformed"); + } + + return [data[0], data[1], data[2], data[3], data[4]]; +} + +function readLockupScheduleData(value: unknown): [unknown, unknown] { + if (!Array.isArray(value) || value.length < 2) { + throw new Error("lockup_created event schedule is malformed"); + } + + return [value[0], value[1]]; +} + +function toText(value: unknown, field: string): string { + if (typeof value === "string") return value; + + if (value === undefined || value === null) { + throw new Error(`lockup_created event is missing ${field}`); + } + + return String(value); +} + +function toBigInt(value: unknown, field: string): bigint { + if (typeof value === "bigint") return value; + + if (typeof value === "number" && Number.isInteger(value)) { + return BigInt(value); + } + + if (typeof value === "string" && value.length > 0) { + return BigInt(value); + } + + throw new Error(`lockup_created event is missing ${field}`); +} diff --git a/streams/src/handlers/tests/flowCreated.test.ts b/streams/src/handlers/tests/flowCreated.test.ts index ab1d94a..0f88f2c 100644 --- a/streams/src/handlers/tests/flowCreated.test.ts +++ b/streams/src/handlers/tests/flowCreated.test.ts @@ -16,11 +16,27 @@ describe("flow created handler", () => { id: "flow-contract:7", contractId: "flow-contract", streamId: 7n, + streamType: "flow", sender: "sender", recipient: "recipient", token: "token", ratePerSecond: "1000", + startTime: null, + endTime: null, + startTimeReadable: null, + endTimeReadable: null, + status: "active", + remainingAmount: "0", snapshotTime: 123n, + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", + payloadRaw: '["sender","recipient","token","1000","123"]', + payloadReadable: JSON.stringify({ + sender: "sender", + recipient: "recipient", + token: "token", + rate_per_second: "1000", + snapshot_time: "123", + }), }, ]); }); @@ -56,5 +72,13 @@ function createRecordingStreamStorage(): StreamStorage & { updatedAt: new Date("2026-06-26T00:00:00.000Z"), }; }, + async update(input) { + insertedRecords.push(input); + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, }; } diff --git a/streams/src/handlers/tests/lockupCreated.test.ts b/streams/src/handlers/tests/lockupCreated.test.ts new file mode 100644 index 0000000..652f57e --- /dev/null +++ b/streams/src/handlers/tests/lockupCreated.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "vitest"; + +import type { StreamIndexerEvent } from "../../indexer/types.js"; +import type { LockupLifecycleStorage } from "../../lockupEvents/types.js"; +import type { StreamStorage } from "../../streamRecords/types.js"; +import { createLockupCreatedHandler } from "../lockupCreated.js"; + +describe("lockup created handler", () => { + test("writes a stream record and lockup history record from the lockup_created event payload", async () => { + const streamStorage = createRecordingStreamStorage(); + const lockupLifecycleStorage = createRecordingLockupLifecycleStorage(); + const handler = createLockupCreatedHandler(streamStorage, lockupLifecycleStorage); + const event = createLockupCreatedEvent(); + + await expect(handler(event)).resolves.toBeUndefined(); + + expect(streamStorage.insertedRecords).toEqual([ + { + id: "lockup-contract:1", + contractId: "lockup-contract", + streamId: 1n, + streamType: "lockup", + sender: "sender", + recipient: "recipient", + token: "token", + ratePerSecond: "0", + startTime: 1782983118n, + endTime: 1783415118n, + startTimeReadable: "2026-07-02T09:05:18.000Z", + endTimeReadable: "2026-07-07T09:05:18.000Z", + status: "active", + remainingAmount: "10000000000", + snapshotTime: 1782983118n, + snapshotTimeReadable: "2026-07-02T09:05:18.000Z", + payloadRaw: + '["sender","recipient","token","10000000000",["1782983118","1783415118","0",true]]', + payloadReadable: JSON.stringify({ + sender: "sender", + recipient: "recipient", + token: "token", + total_amount: "10000000000", + start_time: "2026-07-02T09:05:18.000Z", + end_time: "2026-07-07T09:05:18.000Z", + cliff_time: null, + cancelable: true, + }), + }, + ]); + + expect(lockupLifecycleStorage.insertedRecords).toEqual([ + { + id: "lockup-contract:101:tx-a:4", + contractId: "lockup-contract", + streamId: 1n, + eventName: "lockup_created", + payload: + '["sender","recipient","token","10000000000",["1782983118","1783415118","0",true]]', + payloadReadable: JSON.stringify({ + sender: "sender", + recipient: "recipient", + token: "token", + total_amount: "10000000000", + start_time: "2026-07-02T09:05:18.000Z", + end_time: "2026-07-07T09:05:18.000Z", + cliff_time: null, + cancelable: true, + }), + ledgerNumber: 101n, + transactionHash: "tx-a", + eventIndex: 4, + }, + ]); + }); +}); + +function createLockupCreatedEvent(): StreamIndexerEvent { + return { + name: "lockup_created", + contractId: "lockup-contract", + ledgerNumber: 101n, + transactionHash: "tx-a", + eventIndex: 4, + topics: ["lockup_created", 1n], + data: ["sender", "recipient", "token", 10000000000n, [1782983118n, 1783415118n, 0n, true]], + }; +} + +function createRecordingStreamStorage(): StreamStorage & { + insertedRecords: unknown[]; +} { + const insertedRecords: unknown[] = []; + + return { + insertedRecords, + async findById() { + return undefined; + }, + async insert(input) { + insertedRecords.push(input); + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + async update(input) { + insertedRecords.push(input); + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + }; +} + +function createRecordingLockupLifecycleStorage(): LockupLifecycleStorage & { + insertedRecords: unknown[]; +} { + const insertedRecords: unknown[] = []; + + return { + insertedRecords, + async findById() { + return undefined; + }, + async insert(input) { + insertedRecords.push(input); + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + async update(input) { + insertedRecords.push(input); + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + }; +} diff --git a/streams/src/indexer/runner.ts b/streams/src/indexer/runner.ts index 9c2c5dd..8fef2bc 100644 --- a/streams/src/indexer/runner.ts +++ b/streams/src/indexer/runner.ts @@ -8,16 +8,44 @@ export async function runStreamIndexerBatch( options.startLedger, ); + logStreamEvent("cursor-resolved", { + cursorName: options.cursorName, + lastProcessedLedger: cursor.lastProcessedLedger, + startLedger: options.startLedger, + }); + const batch = await options.eventSource.fetchEvents({ fromLedger: cursor.lastProcessedLedger + 1n, }); + logStreamEvent("batch-fetched", { + cursorName: options.cursorName, + fromLedger: cursor.lastProcessedLedger + 1n, + latestLedger: batch.latestLedger, + fetchedEvents: batch.events.length, + }); + let processedEvents = 0; let skippedEvents = 0; for (const event of batch.events) { + logStreamEvent("event-seen", { + cursorName: options.cursorName, + eventId: createEventId(event), + contractId: event.contractId, + ledgerNumber: event.ledgerNumber, + transactionHash: event.transactionHash, + eventIndex: event.eventIndex, + eventName: event.name, + }); + if (await options.indexedEventRepository.hasIndexedEvent(event)) { skippedEvents += 1; + logStreamEvent("event-skipped", { + cursorName: options.cursorName, + eventId: createEventId(event), + eventName: event.name, + }); continue; } @@ -27,13 +55,32 @@ export async function runStreamIndexerBatch( throw new Error(`No stream event handler registered for "${event.name}"`); } + logStreamEvent("handler-started", { + cursorName: options.cursorName, + eventId: createEventId(event), + handler: event.name, + }); + await handler(event); await options.indexedEventRepository.recordIndexedEvent(event); processedEvents += 1; + + logStreamEvent("event-processed", { + cursorName: options.cursorName, + eventId: createEventId(event), + handler: event.name, + }); } await options.cursorRepository.advanceCursor(options.cursorName, batch.latestLedger); + logStreamEvent("cursor-advanced", { + cursorName: options.cursorName, + latestLedger: batch.latestLedger, + processedEvents, + skippedEvents, + }); + return { fetchedEvents: batch.events.length, processedEvents, @@ -41,3 +88,34 @@ export async function runStreamIndexerBatch( lastProcessedLedger: batch.latestLedger, }; } + +function logStreamEvent(stage: string, details: Record): void { + console.info( + JSON.stringify( + sanitizeForJson({ + source: "stream-indexer", + stage, + ...details, + }), + ), + ); +} + +function createEventId(event: { + contractId: string; + ledgerNumber: bigint; + transactionHash: string; + eventIndex: number; +}): string { + return `${event.contractId}:${event.ledgerNumber}:${event.transactionHash}:${event.eventIndex}`; +} + +function sanitizeForJson(value: unknown): unknown { + if (typeof value === "bigint") return value.toString(); + if (Array.isArray(value)) return value.map(sanitizeForJson); + if (value === null || typeof value !== "object") return value; + + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [key, sanitizeForJson(nestedValue)]), + ); +} diff --git a/streams/src/indexer/sorobanEventSource.ts b/streams/src/indexer/sorobanEventSource.ts index 5119ac4..d1bdca9 100644 --- a/streams/src/indexer/sorobanEventSource.ts +++ b/streams/src/indexer/sorobanEventSource.ts @@ -1,3 +1,4 @@ +import { streamEventCatalog } from "../events/catalog.js"; import type { SorobanRpcEvent, SorobanRpcGetEventsRequest, @@ -12,15 +13,15 @@ export function createSorobanStreamEventSource( ): StreamEventSource { return { async fetchEvents(request) { + const topicFilters = buildTopicFilters(options.eventNames); + const getEventsRequest: SorobanRpcGetEventsRequest = { startLedger: toSafeLedgerNumber(request.fromLedger), - filters: [ - { - type: "contract", - contractIds: options.contractIds, - topics: options.eventNames.map((eventName) => [eventName]), - }, - ], + filters: topicFilters.map((topics) => ({ + type: "contract", + contractIds: options.contractIds, + topics, + })), }; if (options.limit !== undefined) { @@ -28,6 +29,24 @@ export function createSorobanStreamEventSource( } const response = await options.client.getEvents(getEventsRequest); + logStreamEvent("fetch", { + fromLedger: request.fromLedger, + latestLedger: response.latestLedger, + eventCount: response.events.length, + contractIds: [...options.contractIds], + eventNames: [...options.eventNames], + }); + + for (const event of response.events) { + logStreamEvent("raw-event", { + contractId: event.contractId, + ledger: event.ledger, + txHash: event.txHash, + eventIndex: event.eventIndex, + id: event.id, + topics: event.topic, + }); + } return { latestLedger: BigInt(response.latestLedger), @@ -37,6 +56,33 @@ export function createSorobanStreamEventSource( }; } +function buildTopicFilters(eventNames: readonly string[]): string[][][] { + const topicPaths = eventNames.map((eventName) => buildTopicPath(eventName)); + const batches: string[][][] = []; + + for (let index = 0; index < topicPaths.length; index += 5) { + batches.push(topicPaths.slice(index, index + 5)); + } + + return batches; +} + +function buildTopicPath(eventName: string): string[] { + const definition = streamEventCatalog[eventName as keyof typeof streamEventCatalog]; + + if (definition === undefined) { + throw new Error(`Unknown stream event "${eventName}"`); + } + + const topicPath = [eventName]; + + for (let index = 1; index < definition.topics.length; index += 1) { + topicPath.push("*"); + } + + return topicPath; +} + function mapSorobanEvent(event: SorobanRpcEvent): StreamIndexerEvent { const eventName = event.topic[0]; @@ -76,3 +122,25 @@ function toSafeLedgerNumber(ledger: StreamEventFetchRequest["fromLedger"]): numb return ledgerNumber; } + +function logStreamEvent(stage: string, details: Record): void { + console.info( + JSON.stringify( + sanitizeForJson({ + source: "soroban", + stage, + ...details, + }), + ), + ); +} + +function sanitizeForJson(value: unknown): unknown { + if (typeof value === "bigint") return value.toString(); + if (Array.isArray(value)) return value.map(sanitizeForJson); + if (value === null || typeof value !== "object") return value; + + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [key, sanitizeForJson(nestedValue)]), + ); +} diff --git a/streams/src/indexer/stellarSdkEventClient.ts b/streams/src/indexer/stellarSdkEventClient.ts index 7aa8617..85b129b 100644 --- a/streams/src/indexer/stellarSdkEventClient.ts +++ b/streams/src/indexer/stellarSdkEventClient.ts @@ -3,6 +3,7 @@ import type { SorobanRpcEvent, SorobanRpcEventClient, SorobanRpcGetEventsRequest, + SorobanRpcLedgerValue, StellarSdkEventResponse, StellarSdkSorobanRpcEventClientOptions, } from "./types.js"; @@ -13,6 +14,31 @@ export function createStellarSdkSorobanRpcEventClient( const server = options.server ?? new rpc.Server(options.rpcUrl); return { + async getLatestLedger() { + const response = await server.getLatestLedger(); + + if (typeof response === "number" || typeof response === "bigint") { + return response; + } + + if (response !== null && typeof response === "object") { + const ledger = response as { + sequence?: SorobanRpcLedgerValue; + latestLedger?: SorobanRpcLedgerValue; + }; + + if (ledger.sequence !== undefined) { + return ledger.sequence; + } + + if (ledger.latestLedger !== undefined) { + return ledger.latestLedger; + } + } + + throw new Error("Soroban SDK latest ledger response is missing a usable ledger value"); + }, + async getEvents(request) { const response = await server.getEvents({ startLedger: request.startLedger, diff --git a/streams/src/indexer/tests/sorobanEventSource.test.ts b/streams/src/indexer/tests/sorobanEventSource.test.ts index 7e2cf36..7f36f26 100644 --- a/streams/src/indexer/tests/sorobanEventSource.test.ts +++ b/streams/src/indexer/tests/sorobanEventSource.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; +import { streamEventCatalog } from "../../events/catalog.js"; import { createSorobanStreamEventSource } from "../sorobanEventSource.js"; import type { SorobanRpcEventClient, SorobanRpcGetEventsResponse } from "../types.js"; @@ -42,13 +43,10 @@ describe("Soroban stream event source", () => { expect(client.requests).toEqual([ { startLedger: 101, - filters: [ - { - type: "contract", - contractIds: ["flow-contract", "lockup-contract"], - topics: [["flow_created"], ["lockup_created"]], - }, - ], + filters: createExpectedFilters( + ["flow-contract", "lockup-contract"], + ["flow_created", "lockup_created"], + ), pagination: { limit: 50, }, @@ -56,6 +54,30 @@ describe("Soroban stream event source", () => { ]); }); + test("splits large event-name filters into groups of four", async () => { + const client = createRecordingRpcClient({ + latestLedger: "105", + events: [], + }); + const source = createSorobanStreamEventSource({ + client, + contractIds: ["flow-contract"], + eventNames: ["flow_created", "flow_deposit", "flow_withdraw", "flow_adjusted", "flow_paused"], + }); + + await source.fetchEvents({ fromLedger: 101n }); + + expect(client.requests).toEqual([ + { + startLedger: 101, + filters: createExpectedFilters( + ["flow-contract"], + ["flow_created", "flow_deposit", "flow_withdraw", "flow_adjusted", "flow_paused"], + ), + }, + ]); + }); + test("derives the event index from a Soroban event id when eventIndex is not present", async () => { const client = createRecordingRpcClient({ latestLedger: 105n, @@ -117,9 +139,41 @@ function createRecordingRpcClient(response: SorobanRpcGetEventsResponse): Soroba return { requests, + async getLatestLedger() { + return response.latestLedger; + }, async getEvents(request) { requests.push(request); return response; }, }; } + +function createExpectedFilters(contractIds: readonly string[], eventNames: readonly string[]) { + return chunk(eventNames, 5).map((chunkedEventNames) => ({ + type: "contract" as const, + contractIds: [...contractIds], + topics: chunkedEventNames.map((eventName) => buildTopicPath(eventName)), + })); +} + +function buildTopicPath(eventName: string): string[] { + const topicCount = streamEventCatalog[eventName as keyof typeof streamEventCatalog].topics.length; + const topicPath = [eventName]; + + for (let index = 1; index < topicCount; index += 1) { + topicPath.push("*"); + } + + return topicPath; +} + +function chunk(items: readonly T[], size: number): T[][] { + const chunks: T[][] = []; + + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + + return chunks; +} diff --git a/streams/src/indexer/tests/stellarSdkEventClient.test.ts b/streams/src/indexer/tests/stellarSdkEventClient.test.ts index 2c8e0ea..6824398 100644 --- a/streams/src/indexer/tests/stellarSdkEventClient.test.ts +++ b/streams/src/indexer/tests/stellarSdkEventClient.test.ts @@ -2,7 +2,12 @@ import { nativeToScVal } from "@stellar/stellar-sdk"; import { describe, expect, test } from "vitest"; import { createStellarSdkSorobanRpcEventClient } from "../stellarSdkEventClient.js"; -import type { StellarSdkEventResponse, StellarSdkRpcServer, StellarSdkScVal } from "../types.js"; +import type { + SorobanRpcLedgerValue, + StellarSdkEventResponse, + StellarSdkRpcServer, + StellarSdkScVal, +} from "../types.js"; describe("Stellar SDK Soroban RPC event client", () => { test("encodes topic filters and decodes SDK event responses", async () => { @@ -55,6 +60,7 @@ describe("Stellar SDK Soroban RPC event client", () => { }, ], }); + await expect(client.getLatestLedger()).resolves.toBe(105); expect(server.requests).toEqual([ { startLedger: 101, @@ -76,6 +82,9 @@ function createRecordingSdkServer(response: Awaited { + return response.latestLedger; + }, async getEvents(request) { requests.push(request); return response; diff --git a/streams/src/indexer/types.ts b/streams/src/indexer/types.ts index 280700c..89c7a68 100644 --- a/streams/src/indexer/types.ts +++ b/streams/src/indexer/types.ts @@ -78,6 +78,7 @@ export type SorobanRpcGetEventsResponse = { export type SorobanRpcEventClient = { getEvents(request: SorobanRpcGetEventsRequest): Promise; + getLatestLedger(): Promise; }; export type SorobanStreamEventSourceOptions = { @@ -87,7 +88,7 @@ export type SorobanStreamEventSourceOptions = { limit?: number; }; -export type StellarSdkRpcServer = Pick; +export type StellarSdkRpcServer = Pick; export type StellarSdkScVal = xdr.ScVal; diff --git a/streams/src/lockupEvents/lockupLifecycle.ts b/streams/src/lockupEvents/lockupLifecycle.ts index 300f81f..81d5c2e 100644 --- a/streams/src/lockupEvents/lockupLifecycle.ts +++ b/streams/src/lockupEvents/lockupLifecycle.ts @@ -1,9 +1,20 @@ +import { + createLockupLifecycleReadablePayload, + serializeReadablePayload, +} from "../events/readablePayload.js"; import type { StreamIndexerEvent } from "../indexer/types.js"; +import { subtractStreamRemainingAmount, updateStreamSnapshot } from "../streamRecords/snapshot.js"; +import type { StreamStorage } from "../streamRecords/types.js"; import type { LockupLifecycleStorage } from "./types.js"; -export function createLockupLifecycleHandler(storage: LockupLifecycleStorage) { +export function createLockupLifecycleHandler( + storage: LockupLifecycleStorage, + streamStorage: StreamStorage, +) { return async (event: StreamIndexerEvent) => { - await storage.insert(mapLockupLifecycleEvent(event)); + const lifecycleRecord = mapLockupLifecycleEvent(event); + await storage.insert(lifecycleRecord); + await updateStreamSnapshotForLockupLifecycle(event, streamStorage); }; } @@ -16,6 +27,7 @@ function mapLockupLifecycleEvent(event: StreamIndexerEvent) { streamId, eventName: event.name, payload: serializePayload(event.data), + payloadReadable: serializeReadablePayload(createLockupLifecycleReadablePayload(event)), ledgerNumber: event.ledgerNumber, transactionHash: event.transactionHash, eventIndex: event.eventIndex, @@ -41,3 +53,51 @@ function toBigInt(value: unknown, field: string): bigint { throw new Error(`lockup lifecycle event is missing ${field}`); } + +async function updateStreamSnapshotForLockupLifecycle( + event: StreamIndexerEvent, + streamStorage: StreamStorage, +): Promise { + const streamId = toBigInt(event.topics[1], "stream_id"); + const streamRecordId = `${event.contractId}:${streamId}`; + + switch (event.name) { + case "lockup_withdraw": { + const amount = readLockupWithdrawAmount(event.data); + await subtractStreamRemainingAmount(streamStorage, streamRecordId, amount); + return; + } + case "lockup_canceled": + await updateStreamSnapshot(streamStorage, streamRecordId, { + status: "canceled", + remainingAmount: "0", + }); + return; + case "lockup_renounced": + return; + default: + return; + } +} + +function readLockupWithdrawAmount(data: unknown): string { + if (!Array.isArray(data) || data.length < 3) { + throw new Error("lockup_withdraw event payload is malformed"); + } + + return toText(data[2], "amount"); +} + +function toText(value: unknown, field: string): string { + if (typeof value === "string") return value; + + if (typeof value === "bigint" || typeof value === "number") { + return String(value); + } + + if (value === undefined || value === null) { + throw new Error(`lockup lifecycle event is missing ${field}`); + } + + return String(value); +} diff --git a/streams/src/lockupEvents/postgresStorage.ts b/streams/src/lockupEvents/postgresStorage.ts index 6e2602a..6e35ea3 100644 --- a/streams/src/lockupEvents/postgresStorage.ts +++ b/streams/src/lockupEvents/postgresStorage.ts @@ -7,7 +7,7 @@ import type { } from "./types.js"; const lockupLifecycleColumns = - "id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index, created_at"; + "id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index, created_at"; export function createPostgresLockupLifecycleStorage( client: LockupLifecycleSqlClient, @@ -25,13 +25,14 @@ export function createPostgresLockupLifecycleStorage( findById, async insert(input) { const result = await client.query( - `insert into lockup_events (id, contract_id, stream_id, event_name, payload, ledger_number, transaction_hash, event_index) values ($1, $2, $3, $4, $5, $6, $7, $8) on conflict (id) do nothing returning ${lockupLifecycleColumns}`, + `insert into lockup_events (id, contract_id, stream_id, event_name, payload, payload_readable, ledger_number, transaction_hash, event_index) values ($1, $2, $3, $4, $5, $6, $7, $8, $9) on conflict (id) do nothing returning ${lockupLifecycleColumns}`, [ input.id, input.contractId, input.streamId.toString(), input.eventName, input.payload, + input.payloadReadable, input.ledgerNumber.toString(), input.transactionHash, input.eventIndex, @@ -68,6 +69,7 @@ function mapLockupLifecycleRow( streamId: BigInt(row.stream_id), eventName: row.event_name, payload: row.payload, + payloadReadable: row.payload_readable, ledgerNumber: BigInt(row.ledger_number), transactionHash: row.transaction_hash, eventIndex: row.event_index, diff --git a/streams/src/lockupEvents/tests/lockupLifecycle.test.ts b/streams/src/lockupEvents/tests/lockupLifecycle.test.ts index 06f9370..206c80c 100644 --- a/streams/src/lockupEvents/tests/lockupLifecycle.test.ts +++ b/streams/src/lockupEvents/tests/lockupLifecycle.test.ts @@ -1,13 +1,15 @@ import { describe, expect, test } from "vitest"; import type { StreamIndexerEvent } from "../../indexer/types.js"; +import type { StreamStorage } from "../../streamRecords/types.js"; import { createLockupLifecycleHandler } from "../lockupLifecycle.js"; import type { LockupLifecycleStorage } from "../types.js"; describe("lockup lifecycle handler", () => { test("writes a lockup lifecycle record from the event payload", async () => { const storage = createRecordingLockupLifecycleStorage(); - const handler = createLockupLifecycleHandler(storage); + const streamStorage = createRecordingStreamStorage(); + const handler = createLockupLifecycleHandler(storage, streamStorage); await expect( handler({ @@ -28,11 +30,42 @@ describe("lockup lifecycle handler", () => { streamId: 9n, eventName: "lockup_canceled", payload: serializePayload(["sender", "recipient", 100n, 25n]), + payloadReadable: serializePayload({ + sender: "sender", + recipient: "recipient", + sender_amount: "100", + recipient_amount: "25", + }), ledgerNumber: 204n, transactionHash: "tx-d", eventIndex: 8, }, ]); + + expect(streamStorage.updatedRecords).toEqual([ + { + id: "lockup-contract:9", + contractId: "lockup-contract", + streamId: 9n, + streamType: "lockup", + sender: "sender", + recipient: "recipient", + token: "token", + ratePerSecond: "0", + startTime: 1782983118n, + endTime: 1783415118n, + startTimeReadable: "2026-07-02T09:05:18.000Z", + endTimeReadable: "2026-07-07T09:05:18.000Z", + status: "canceled", + remainingAmount: "0", + snapshotTime: 1782983118n, + snapshotTimeReadable: "2026-07-02T09:05:18.000Z", + payloadRaw: "{}", + payloadReadable: "{}", + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }, + ]); }); }); @@ -56,6 +89,54 @@ function createRecordingLockupLifecycleStorage(): LockupLifecycleStorage & { }; } +function createRecordingStreamStorage(): StreamStorage & { + updatedRecords: unknown[]; +} { + const updatedRecords: unknown[] = []; + + return { + updatedRecords, + async findById() { + return { + id: "lockup-contract:9", + contractId: "lockup-contract", + streamId: 9n, + streamType: "lockup", + sender: "sender", + recipient: "recipient", + token: "token", + ratePerSecond: "0", + startTime: 1782983118n, + endTime: 1783415118n, + startTimeReadable: "2026-07-02T09:05:18.000Z", + endTimeReadable: "2026-07-07T09:05:18.000Z", + status: "active", + remainingAmount: "25", + snapshotTime: 1782983118n, + snapshotTimeReadable: "2026-07-02T09:05:18.000Z", + payloadRaw: "{}", + payloadReadable: "{}", + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + async insert(input) { + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + async update(input) { + updatedRecords.push(input); + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + }; +} + function serializePayload(value: unknown): string { return JSON.stringify(value, (_, current) => typeof current === "bigint" ? current.toString() : current, diff --git a/streams/src/lockupEvents/tests/postgresStorage.test.ts b/streams/src/lockupEvents/tests/postgresStorage.test.ts index 61e230d..a97cc07 100644 --- a/streams/src/lockupEvents/tests/postgresStorage.test.ts +++ b/streams/src/lockupEvents/tests/postgresStorage.test.ts @@ -18,6 +18,12 @@ describe("PostgreSQL lockup lifecycle storage", () => { streamId: 9n, eventName: "lockup_canceled", payload: serializePayload(["sender", "recipient", 100n, 25n]), + payloadReadable: serializePayload({ + sender: "sender", + recipient: "recipient", + sender_amount: "100", + recipient_amount: "25", + }), ledgerNumber: 204n, transactionHash: "tx-d", eventIndex: 8, @@ -40,6 +46,12 @@ describe("PostgreSQL lockup lifecycle storage", () => { streamId: 9n, eventName: "lockup_canceled", payload: serializePayload(["sender", "recipient", 100n, 25n]), + payloadReadable: serializePayload({ + sender: "sender", + recipient: "recipient", + sender_amount: "100", + recipient_amount: "25", + }), ledgerNumber: 204n, transactionHash: "tx-d", eventIndex: 8, @@ -58,6 +70,12 @@ function createLockupLifecycleRow() { stream_id: "9", event_name: "lockup_canceled", payload: serializePayload(["sender", "recipient", 100n, 25n]), + payload_readable: serializePayload({ + sender: "sender", + recipient: "recipient", + sender_amount: "100", + recipient_amount: "25", + }), ledger_number: "204", transaction_hash: "tx-d", event_index: 8, diff --git a/streams/src/lockupEvents/types.ts b/streams/src/lockupEvents/types.ts index 608803d..2aac4e2 100644 --- a/streams/src/lockupEvents/types.ts +++ b/streams/src/lockupEvents/types.ts @@ -6,6 +6,7 @@ export type LockupLifecycleEntity = { streamId: bigint; eventName: string; payload: string; + payloadReadable: string; ledgerNumber: bigint; transactionHash: string; eventIndex: number; @@ -18,6 +19,7 @@ export type LockupLifecycleInput = { streamId: bigint; eventName: string; payload: string; + payloadReadable: string; ledgerNumber: bigint; transactionHash: string; eventIndex: number; @@ -40,6 +42,7 @@ export type LockupLifecycleRow = { stream_id: string | number | bigint; event_name: string; payload: string; + payload_readable: string; ledger_number: string | number | bigint; transaction_hash: string; event_index: number; diff --git a/streams/src/main.ts b/streams/src/main.ts index 95ad359..87aa6b0 100644 --- a/streams/src/main.ts +++ b/streams/src/main.ts @@ -1,19 +1,28 @@ -import { createDatabasePool, loadConfig } from "@fundable-indexer/common"; +import { fileURLToPath } from "node:url"; +import { createDatabasePool, loadConfig, loadRuntimeEnv } from "@fundable-indexer/common"; import { createStreamIndexerApp } from "./app/index.js"; import { createStreamIndexerRuntime } from "./index.js"; async function main(): Promise { const runtime = createStreamIndexerRuntime({ - loadConfig, + loadConfig: () => loadConfig(loadRuntimeEnv(fileURLToPath(new URL(".", import.meta.url)))), createDatabasePool, createStreamIndexerApp, handlers: {}, }); + const stop = async (): Promise => { + await runtime.close(); + }; + + process.once("SIGINT", stop); + process.once("SIGTERM", stop); try { - await runtime.runOnce(); + await runtime.run(); } finally { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); await runtime.close(); } } diff --git a/streams/src/runtime/index.ts b/streams/src/runtime/index.ts index 2003b8a..393cfa3 100644 --- a/streams/src/runtime/index.ts +++ b/streams/src/runtime/index.ts @@ -7,6 +7,8 @@ export function createStreamIndexerRuntime( const pool = dependencies.createDatabasePool({ databaseUrl: config.databaseUrl, }); + const shutdownController = new AbortController(); + let closed = false; const appCall = { config, @@ -24,8 +26,44 @@ export function createStreamIndexerRuntime( async runOnce() { return app.runOnce(); }, + async run() { + while (!closed) { + await app.runOnce(); + + if (closed) { + break; + } + + await waitForPollInterval(config.pollIntervalMs, shutdownController.signal); + } + }, async close() { + if (closed) { + return; + } + + closed = true; + shutdownController.abort(); await pool.end(); }, }; } + +function waitForPollInterval(intervalMs: number, signal: AbortSignal): Promise { + if (intervalMs <= 0) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const timeout = setTimeout(resolve, intervalMs); + + signal.addEventListener( + "abort", + () => { + clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + }); +} diff --git a/streams/src/runtime/tests/index.test.ts b/streams/src/runtime/tests/index.test.ts index 07094af..bd6500b 100644 --- a/streams/src/runtime/tests/index.test.ts +++ b/streams/src/runtime/tests/index.test.ts @@ -33,9 +33,37 @@ describe("stream indexer runtime", () => { }, ]); }); + + test("keeps running until closed", async () => { + const loadConfig = () => createRuntimeConfig({ pollIntervalMs: 10 }); + const createDatabasePool = () => createRecordingPool(); + const createStreamIndexerApp = () => createLoopingApp(); + + const runtime = createStreamIndexerRuntime({ + loadConfig, + createDatabasePool, + createStreamIndexerApp, + handlers: {}, + }); + + const runPromise = runtime.run(); + + await Promise.resolve(); + await Promise.resolve(); + + expect(runtime.appCalls).toHaveLength(1); + expect(runtime.appCalls[0]).toEqual({ + config: runtime.config, + sqlClient: runtime.pool, + handlers: {}, + }); + + await runtime.close(); + await expect(runPromise).resolves.toBeUndefined(); + }); }); -function createRuntimeConfig(): RuntimeConfig { +function createRuntimeConfig(overrides?: Partial): RuntimeConfig { return { nodeEnv: "test", port: 4000, @@ -44,6 +72,7 @@ function createRuntimeConfig(): RuntimeConfig { sorobanRpcUrl: "https://soroban-testnet.stellar.org", stellarNetworkPassphrase: "Test SDF Network ; September 2015", pollIntervalMs: 5000, + ...overrides, }; } @@ -66,3 +95,11 @@ function createRecordingApp(): StreamIndexerApp { }, }; } + +function createLoopingApp(): StreamIndexerApp { + return { + async runOnce() { + return { ran: true } as never; + }, + }; +} diff --git a/streams/src/runtime/types.ts b/streams/src/runtime/types.ts index 3bb4a4d..145c578 100644 --- a/streams/src/runtime/types.ts +++ b/streams/src/runtime/types.ts @@ -29,5 +29,6 @@ export type StreamIndexerRuntime = { handlers: StreamEventHandlers; }>; runOnce(): Promise; + run(): Promise; close(): Promise; }; diff --git a/streams/src/streamRecords/postgresStorage.ts b/streams/src/streamRecords/postgresStorage.ts index 042229e..90f57a2 100644 --- a/streams/src/streamRecords/postgresStorage.ts +++ b/streams/src/streamRecords/postgresStorage.ts @@ -7,7 +7,7 @@ import type { } from "./types.js"; const streamColumns = - "id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at"; + "id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable, created_at, updated_at"; export function createPostgresStreamStorage(client: StreamSqlClient): StreamStorage { async function findById(id: string): Promise { @@ -23,28 +23,105 @@ export function createPostgresStreamStorage(client: StreamSqlClient): StreamStor findById, async insert(input) { const result = await client.query( - `insert into streams (id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time) values ($1, $2, $3, $4, $5, $6, $7, $8) on conflict (id) do nothing returning ${streamColumns}`, - [ - input.id, - input.contractId, - input.streamId.toString(), - input.sender, - input.recipient, - input.token, - input.ratePerSecond, - input.snapshotTime.toString(), - ], + createInsertStreamQuery(), + toStreamValues(input), ); const insertedStream = mapStreamRow(result.rows[0]); if (insertedStream !== undefined) return insertedStream; + return requireStreamRow(input, await findById(input.id)); + }, + async update(input) { + const result = await client.query( + createUpdateStreamQuery(), + toStreamValues(input), + ); + + const updatedStream = mapStreamRow(result.rows[0]); + + if (updatedStream !== undefined) return updatedStream; + return requireStreamRow(input, await findById(input.id)); }, }; } +function createInsertStreamQuery(): string { + return `insert into streams (id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + on conflict (id) do nothing returning ${streamColumns}`; +} + +function createUpdateStreamQuery(): string { + return `update streams + set contract_id = $2, + stream_id = $3, + stream_type = $4, + sender = $5, + recipient = $6, + token = $7, + rate_per_second = $8, + start_time = $9, + end_time = $10, + start_time_readable = $11, + end_time_readable = $12, + status = $13, + remaining_amount = $14, + snapshot_time = $15, + snapshot_time_readable = $16, + payload_raw = $17, + payload_readable = $18, + updated_at = now() + where id = $1 + returning ${streamColumns}`; +} + +function toStreamValues( + input: StreamInput, +): [ + string, + string, + string, + string, + string, + string, + string, + string, + string | null, + string | null, + string | null, + string | null, + string, + string, + string, + string, + string, + string, +] { + return [ + input.id, + input.contractId, + input.streamId.toString(), + input.streamType, + input.sender, + input.recipient, + input.token, + input.ratePerSecond, + input.startTime === null ? null : input.startTime.toString(), + input.endTime === null ? null : input.endTime.toString(), + input.startTimeReadable, + input.endTimeReadable, + input.status, + input.remainingAmount, + input.snapshotTime.toString(), + input.snapshotTimeReadable, + input.payloadRaw, + input.payloadReadable, + ]; +} + function requireStreamRow(input: StreamInput, stream: StreamEntity | undefined): StreamEntity { if (stream === undefined) { throw new Error(`Stream write for "${input.id}" did not return a row`); @@ -60,11 +137,21 @@ function mapStreamRow(row: StreamRow | undefined): StreamEntity | undefined { id: row.id, contractId: row.contract_id, streamId: BigInt(row.stream_id), + streamType: row.stream_type, sender: row.sender, recipient: row.recipient, token: row.token, ratePerSecond: row.rate_per_second, + startTime: row.start_time === null ? null : BigInt(row.start_time), + endTime: row.end_time === null ? null : BigInt(row.end_time), + startTimeReadable: row.start_time_readable, + endTimeReadable: row.end_time_readable, + status: row.status, + remainingAmount: row.remaining_amount, snapshotTime: BigInt(row.snapshot_time), + snapshotTimeReadable: row.snapshot_time_readable, + payloadRaw: row.payload_raw, + payloadReadable: row.payload_readable, createdAt: row.created_at, updatedAt: row.updated_at, }; diff --git a/streams/src/streamRecords/snapshot.ts b/streams/src/streamRecords/snapshot.ts new file mode 100644 index 0000000..8b7b1e8 --- /dev/null +++ b/streams/src/streamRecords/snapshot.ts @@ -0,0 +1,72 @@ +import type { StreamEntity, StreamStorage } from "./types.js"; + +export type StreamSnapshotPatch = Partial< + Pick< + StreamEntity, + | "streamType" + | "sender" + | "recipient" + | "token" + | "ratePerSecond" + | "startTime" + | "endTime" + | "status" + | "remainingAmount" + | "snapshotTime" + | "payloadRaw" + | "payloadReadable" + > +>; + +export async function updateStreamSnapshot( + storage: StreamStorage, + id: string, + patchOrMutator: StreamSnapshotPatch | ((current: StreamEntity) => StreamSnapshotPatch), +): Promise { + const current = await storage.findById(id); + + if (current === undefined) { + throw new Error(`Stream "${id}" was not found`); + } + + const patch = typeof patchOrMutator === "function" ? patchOrMutator(current) : patchOrMutator; + + return storage.update({ + ...current, + ...patch, + }); +} + +export async function addStreamRemainingAmount( + storage: StreamStorage, + id: string, + delta: string, +): Promise { + return updateStreamSnapshot(storage, id, (current) => ({ + remainingAmount: addAmounts(current.remainingAmount, delta), + })); +} + +export async function subtractStreamRemainingAmount( + storage: StreamStorage, + id: string, + delta: string, +): Promise { + return updateStreamSnapshot(storage, id, (current) => ({ + remainingAmount: subtractAmounts(current.remainingAmount, delta), + })); +} + +export function addAmounts(current: string, delta: string): string { + return (BigInt(current) + BigInt(delta)).toString(); +} + +export function subtractAmounts(current: string, delta: string): string { + const next = BigInt(current) - BigInt(delta); + + if (next < 0n) { + throw new Error("stream remaining amount cannot go below zero"); + } + + return next.toString(); +} diff --git a/streams/src/streamRecords/tests/postgresStorage.test.ts b/streams/src/streamRecords/tests/postgresStorage.test.ts index c28a008..b7fccee 100644 --- a/streams/src/streamRecords/tests/postgresStorage.test.ts +++ b/streams/src/streamRecords/tests/postgresStorage.test.ts @@ -16,17 +16,27 @@ describe("PostgreSQL stream storage", () => { id: "flow-contract:7", contractId: "flow-contract", streamId: 7n, + streamType: "flow", sender: "sender", recipient: "recipient", token: "token", ratePerSecond: "1000", + startTime: null, + endTime: null, + startTimeReadable: null, + endTimeReadable: null, + status: "active", + remainingAmount: "0", snapshotTime: 123n, + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", + payloadRaw: "{}", + payloadReadable: "{}", createdAt: new Date("2026-06-26T00:00:00.000Z"), updatedAt: new Date("2026-06-26T00:00:00.000Z"), }); expect(client.queries).toEqual([ { - text: "select id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at from streams where id = $1 limit 1", + text: "select id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable, created_at, updated_at from streams where id = $1 limit 1", values: ["flow-contract:7"], }, ]); @@ -45,11 +55,21 @@ describe("PostgreSQL stream storage", () => { id: "flow-contract:7", contractId: "flow-contract", streamId: 7n, + streamType: "flow", sender: "sender", recipient: "recipient", token: "token", ratePerSecond: "1000", + startTime: null, + endTime: null, + startTimeReadable: null, + endTimeReadable: null, + status: "active", + remainingAmount: "0", snapshotTime: 123n, + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", + payloadRaw: "{}", + payloadReadable: "{}", }), ).resolves.toMatchObject({ id: "flow-contract:7", @@ -58,16 +78,28 @@ describe("PostgreSQL stream storage", () => { }); expect(client.queries).toEqual([ { - text: "insert into streams (id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time) values ($1, $2, $3, $4, $5, $6, $7, $8) on conflict (id) do nothing returning id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at", + text: `insert into streams (id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + on conflict (id) do nothing returning id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable, created_at, updated_at`, values: [ "flow-contract:7", "flow-contract", "7", + "flow", "sender", "recipient", "token", "1000", + null, + null, + null, + null, + "active", + "0", "123", + "1970-01-01T00:02:03.000Z", + "{}", + "{}", ], }, ]); @@ -87,11 +119,21 @@ describe("PostgreSQL stream storage", () => { id: "flow-contract:7", contractId: "flow-contract", streamId: 7n, + streamType: "flow", sender: "sender", recipient: "recipient", token: "token", ratePerSecond: "1000", + startTime: null, + endTime: null, + startTimeReadable: null, + endTimeReadable: null, + status: "active", + remainingAmount: "0", snapshotTime: 123n, + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", + payloadRaw: "{}", + payloadReadable: "{}", }), ).resolves.toMatchObject({ id: "flow-contract:7", @@ -99,38 +141,152 @@ describe("PostgreSQL stream storage", () => { }); expect(client.queries).toEqual([ { - text: "insert into streams (id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time) values ($1, $2, $3, $4, $5, $6, $7, $8) on conflict (id) do nothing returning id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at", + text: `insert into streams (id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + on conflict (id) do nothing returning id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable, created_at, updated_at`, values: [ "flow-contract:7", "flow-contract", "7", + "flow", "sender", "recipient", "token", "1000", + null, + null, + null, + null, + "active", + "0", "123", + "1970-01-01T00:02:03.000Z", + "{}", + "{}", ], }, { - text: "select id, contract_id, stream_id, sender, recipient, token, rate_per_second, snapshot_time, created_at, updated_at from streams where id = $1 limit 1", + text: "select id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable, created_at, updated_at from streams where id = $1 limit 1", values: ["flow-contract:7"], }, ]); }); + + test("updates a stream snapshot", async () => { + const client = createRecordingSqlClient([ + { + rows: [ + createStreamRow({ + rate_per_second: "1200", + remaining_amount: "1250", + payload_raw: '["sender","recipient","token","1000","123"]', + payload_readable: + '{"sender":"sender","recipient":"recipient","token":"token","rate_per_second":"1000","snapshot_time":"123"}', + }), + ], + }, + ]); + const storage = createPostgresStreamStorage(client); + + await expect( + storage.update({ + id: "flow-contract:7", + contractId: "flow-contract", + streamId: 7n, + streamType: "flow", + sender: "sender", + recipient: "recipient", + token: "token", + ratePerSecond: "1200", + startTime: null, + endTime: null, + startTimeReadable: null, + endTimeReadable: null, + status: "active", + remainingAmount: "1250", + snapshotTime: 123n, + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", + payloadRaw: '["sender","recipient","token","1000","123"]', + payloadReadable: + '{"sender":"sender","recipient":"recipient","token":"token","rate_per_second":"1000","snapshot_time":"123"}', + }), + ).resolves.toMatchObject({ + id: "flow-contract:7", + streamType: "flow", + ratePerSecond: "1200", + remainingAmount: "1250", + }); + expect(client.queries).toEqual([ + { + text: `update streams + set contract_id = $2, + stream_id = $3, + stream_type = $4, + sender = $5, + recipient = $6, + token = $7, + rate_per_second = $8, + start_time = $9, + end_time = $10, + start_time_readable = $11, + end_time_readable = $12, + status = $13, + remaining_amount = $14, + snapshot_time = $15, + snapshot_time_readable = $16, + payload_raw = $17, + payload_readable = $18, + updated_at = now() + where id = $1 + returning id, contract_id, stream_id, stream_type, sender, recipient, token, rate_per_second, start_time, end_time, start_time_readable, end_time_readable, status, remaining_amount, snapshot_time, snapshot_time_readable, payload_raw, payload_readable, created_at, updated_at`, + values: [ + "flow-contract:7", + "flow-contract", + "7", + "flow", + "sender", + "recipient", + "token", + "1200", + null, + null, + null, + null, + "active", + "1250", + "123", + "1970-01-01T00:02:03.000Z", + '["sender","recipient","token","1000","123"]', + '{"sender":"sender","recipient":"recipient","token":"token","rate_per_second":"1000","snapshot_time":"123"}', + ], + }, + ]); + }); }); -function createStreamRow() { +function createStreamRow(overrides: Record = {}) { return { id: "flow-contract:7", contract_id: "flow-contract", stream_id: "7", + stream_type: "flow", sender: "sender", recipient: "recipient", token: "token", rate_per_second: "1000", + start_time: null, + end_time: null, + start_time_readable: null, + end_time_readable: null, + status: "active", + remaining_amount: "0", snapshot_time: "123", + snapshot_time_readable: "1970-01-01T00:02:03.000Z", + payload_raw: "{}", + payload_readable: "{}", created_at: new Date("2026-06-26T00:00:00.000Z"), updated_at: new Date("2026-06-26T00:00:00.000Z"), + ...overrides, }; } diff --git a/streams/src/streamRecords/time.ts b/streams/src/streamRecords/time.ts new file mode 100644 index 0000000..8e7f170 --- /dev/null +++ b/streams/src/streamRecords/time.ts @@ -0,0 +1,7 @@ +export function toReadableUnixTimestamp(value: bigint): string { + return new Date(Number(value) * 1000).toISOString(); +} + +export function toReadableUnixTimestampOrNull(value: bigint | null): string | null { + return value === null ? null : toReadableUnixTimestamp(value); +} diff --git a/streams/src/streamRecords/types.ts b/streams/src/streamRecords/types.ts index efa821c..84d6187 100644 --- a/streams/src/streamRecords/types.ts +++ b/streams/src/streamRecords/types.ts @@ -4,11 +4,21 @@ export type StreamEntity = { id: string; contractId: string; streamId: bigint; + streamType: string; sender: string; recipient: string; token: string; ratePerSecond: string; + startTime: bigint | null; + endTime: bigint | null; + startTimeReadable: string | null; + endTimeReadable: string | null; + status: string; + remainingAmount: string; snapshotTime: bigint; + snapshotTimeReadable: string; + payloadRaw: string; + payloadReadable: string; createdAt: Date; updatedAt: Date; }; @@ -17,16 +27,27 @@ export type StreamInput = { id: string; contractId: string; streamId: bigint; + streamType: string; sender: string; recipient: string; token: string; ratePerSecond: string; + startTime: bigint | null; + endTime: bigint | null; + startTimeReadable: string | null; + endTimeReadable: string | null; + status: string; + remainingAmount: string; snapshotTime: bigint; + snapshotTimeReadable: string; + payloadRaw: string; + payloadReadable: string; }; export type StreamStorage = { findById(id: string): Promise; insert(input: StreamInput): Promise; + update(input: StreamInput): Promise; }; export type StreamSqlResult = { @@ -39,11 +60,21 @@ export type StreamRow = { id: string; contract_id: string; stream_id: string | number | bigint; + stream_type: string; sender: string; recipient: string; token: string; rate_per_second: string; + start_time: string | number | bigint | null; + end_time: string | number | bigint | null; + start_time_readable: string | null; + end_time_readable: string | null; + status: string; + remaining_amount: string; snapshot_time: string | number | bigint; + snapshot_time_readable: string; + payload_raw: string; + payload_readable: string; created_at: Date; updated_at: Date; }; diff --git a/streams/src/withdrawals/flowWithdraw.ts b/streams/src/withdrawals/flowWithdraw.ts index 1dcad70..aceb7f9 100644 --- a/streams/src/withdrawals/flowWithdraw.ts +++ b/streams/src/withdrawals/flowWithdraw.ts @@ -1,9 +1,21 @@ import type { StreamIndexerEvent } from "../indexer/types.js"; +import { subtractStreamRemainingAmount } from "../streamRecords/snapshot.js"; +import type { StreamStorage } from "../streamRecords/types.js"; import type { FlowWithdrawStorage } from "./types.js"; -export function createFlowWithdrawHandler(storage: FlowWithdrawStorage) { +export function createFlowWithdrawHandler( + storage: FlowWithdrawStorage, + streamStorage: StreamStorage, +) { return async (event: StreamIndexerEvent) => { - await storage.insert(mapFlowWithdrawEvent(event)); + const withdrawal = mapFlowWithdrawEvent(event); + await storage.insert(withdrawal); + + await subtractStreamRemainingAmount( + streamStorage, + `${withdrawal.contractId}:${withdrawal.streamId}`, + withdrawal.amount, + ); }; } diff --git a/streams/src/withdrawals/tests/flowWithdraw.test.ts b/streams/src/withdrawals/tests/flowWithdraw.test.ts index e94e37c..a3fcc06 100644 --- a/streams/src/withdrawals/tests/flowWithdraw.test.ts +++ b/streams/src/withdrawals/tests/flowWithdraw.test.ts @@ -1,13 +1,15 @@ import { describe, expect, test } from "vitest"; import type { StreamIndexerEvent } from "../../indexer/types.js"; +import type { StreamStorage } from "../../streamRecords/types.js"; import { createFlowWithdrawHandler } from "../flowWithdraw.js"; import type { FlowWithdrawStorage } from "../types.js"; describe("flow withdraw handler", () => { test("writes a flow withdraw record from the flow_withdraw event payload", async () => { const storage = createRecordingFlowWithdrawStorage(); - const handler = createFlowWithdrawHandler(storage); + const streamStorage = createRecordingStreamStorage(); + const handler = createFlowWithdrawHandler(storage, streamStorage); await expect(handler(createFlowWithdrawEvent())).resolves.toBeUndefined(); @@ -24,6 +26,31 @@ describe("flow withdraw handler", () => { eventIndex: 6, }, ]); + + expect(streamStorage.updatedRecords).toEqual([ + { + id: "flow-contract:7", + contractId: "flow-contract", + streamId: 7n, + streamType: "flow", + sender: "sender", + recipient: "recipient", + token: "token", + ratePerSecond: "1000", + startTime: null, + endTime: null, + startTimeReadable: null, + endTimeReadable: null, + status: "active", + remainingAmount: "750", + snapshotTime: 123n, + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", + payloadRaw: "{}", + payloadReadable: "{}", + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }, + ]); }); }); @@ -58,3 +85,52 @@ function createRecordingFlowWithdrawStorage(): FlowWithdrawStorage & { }, }; } + +function createRecordingStreamStorage(): StreamStorage & { + updatedRecords: unknown[]; +} { + const updatedRecords: unknown[] = []; + + return { + updatedRecords, + async findById() { + return { + id: "flow-contract:7", + contractId: "flow-contract", + streamId: 7n, + streamType: "flow", + sender: "sender", + recipient: "recipient", + token: "token", + ratePerSecond: "1000", + startTime: null, + endTime: null, + startTimeReadable: null, + endTimeReadable: null, + status: "active", + remainingAmount: "1000", + snapshotTime: 123n, + snapshotTimeReadable: "1970-01-01T00:02:03.000Z", + payloadRaw: "{}", + payloadReadable: "{}", + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + async insert(input) { + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + async update(input) { + updatedRecords.push(input); + return { + ...input, + createdAt: new Date("2026-06-26T00:00:00.000Z"), + updatedAt: new Date("2026-06-26T00:00:00.000Z"), + }; + }, + }; +}