diff --git a/docs/pages/docs/query/_meta.ts b/docs/pages/docs/query/_meta.ts index d9a32bfc4..437bb0eea 100644 --- a/docs/pages/docs/query/_meta.ts +++ b/docs/pages/docs/query/_meta.ts @@ -1,6 +1,6 @@ export default { "api-functions": "API functions", - "client": "@ponder/client", + "client": "Client", "graphql": "GraphQL", "direct-sql": "Direct SQL", }; diff --git a/docs/pages/docs/query/client.mdx b/docs/pages/docs/query/client.mdx index 34771c17e..958cfeb99 100644 --- a/docs/pages/docs/query/client.mdx +++ b/docs/pages/docs/query/client.mdx @@ -5,28 +5,18 @@ description: "Query a Ponder app with the `@ponder/client` package." import { Callout, Steps, Tabs } from "nextra/components"; -# `@ponder/client` +# Client queries The `@ponder/client` package is a TypeScript client for querying a Ponder app with end-to-end type safety. -## Example projects - -These example apps demonstrate how to use `@ponder/client`. - -- [**Basic**](https://github.com/ponder-sh/ponder/blob/v0.9/examples/with-client/client/src/index.ts) -- [**NextJs**](https://github.com/ponder-sh/ponder/blob/v0.9/examples/with-nextjs/frontend/src/hooks/useDeposits.ts) - -## Get started - - +## Enable on the server -### Update to `>=0.9.0` + + Client queries are available starting from version `0.9.0`. Read the + [migration guide](/docs/migration-guide#090) for more details. + -`@ponder/client` is available starting from version `0.9.0`. Read the [migration guide](/docs/migration-guide#090) for more details. - -### Register client middleware - -Register the `client` middleware to enable client queries. +Register the `client` middleware on your Ponder server to enable client queries. ```ts filename="src/api/index.ts" {7} import { db } from "ponder:api"; @@ -40,6 +30,118 @@ app.use(client({ db })); export default app; ``` +## Query from React + +The `@ponder/react` package includes React hooks for subscribing to live updates from your database. + + + For an example of how to use `@ponder/client` with `@tanstack/react-query` for + an automatically updating React hook, see the [Next.js + example](https://github.com/ponder-sh/ponder/blob/af25a96b44c0d76e6e4e62557125fe50bc0cad8a/examples/with-nextjs/frontend/src/hooks/useDeposits.ts). + + + + +### Installation + +Install `@ponder/react` and `@tanstack/react-query` in your React project. + +{/* prettier-ignore */} + + +```bash filename="shell" +pnpm add @ponder/react @tanstack/react-query +``` + + +```bash filename="shell" +yarn add @ponder/react @tanstack/react-query +``` + + +```bash filename="shell" +npm add @ponder/react @tanstack/react-query +``` + + + +### Create client + +Create a client using the URL of your Ponder server. Import your Ponder schema into the same file. + +```ts filename="lib/ponder.ts" +import { createClient } from "@ponder/client"; +import * as schema from "../../ponder/ponder.schema"; + +export const client = createClient("http://localhost:42069", { schema }); +``` + +### Wrap app in provider + +Wrap your app with the `PonderProvider` React Context Provider and include the client object you just created. + +```ts filename="app.tsx" {6-8} +import { PonderProvider } from '@ponder/react' +import { client } from './lib/ponder' + +function App() { + return ( + + {/** ... */} + + ) +} +``` + +### Setup TanStack Query + +Inside the `PonderProvider`, wrap your app in a TanStack Query React Context Provider. [Read more](https://tanstack.com/query/latest/docs/framework/react/quick-start) about setting up TanStack Query. + +```ts filename="app.tsx" {5, 10-12} +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { PonderProvider } from '@ponder/react' +import { client } from './lib/ponder' + +const queryClient = new QueryClient() + +function App() { + return ( + + + {/** ... */} + + + ) +} +``` + +### Use the hook + +```ts filename="components/Deposits.tsx" +import { usePonderQuery } from '@ponder/react' +import { schema } from '../lib/ponder' + +export function Deposits() { + const { data, isError, isPending } = usePonderQuery({ + queryFn: (db) => + db.select() + .from(schema.depositEvent) + .orderBy(desc(schema.depositEvent.timestamp)) + .limit(10), + }); + + if (isPending) return
Loading deposits
+ if (isError) return
Error fetching deposits
+ return
Deposits: {data}
+} +``` + +
+ +## Query from Node.js + + + ### Install `@ponder/client` Install the `@ponder/client` package in your client project. This package works in any JavaScript environment, including the browser, server-side scripts, and both client and server code from web frameworks like Next.js. @@ -63,11 +165,24 @@ Install the `@ponder/client` package in your client project. This package works -### Create a client and send a request +### Setup client + +Create a client using the URL of your Ponder server. Import your Ponder schema into the same file. + +```ts {4} +import { createClient } from "@ponder/client"; +import * as schema from "../../ponder/ponder.schema"; + +const client = createClient("http://localhost:42069", { schema }); + +export { client, schema }; +``` + +### Run a query -Create a client using the root path of your Ponder server. For example, local development should use `http://localhost:42069`. +Use the `client.db` method to execute a `SELECT` statement using Drizzle. The query builder is fully type-safe to provide static query validation and inferred result types. -```ts {4,6} +```ts {6} import { createClient } from "@ponder/client"; import * as schema from "../../ponder/ponder.schema"; @@ -95,7 +210,11 @@ const result = await client.db.select().from(schema.account).limit(10); ### `client.live` -Coming soon. +Subscribe to live updates. + +When you initiate a live query, the client opens a persistent HTTP connection with the server using Server-Sent Events (SSE). Then, the server notifies the client whenever a new block gets indexed. If a query result is no longer valid, the client will immediately refetch it to receive the latest result. This approach achieves low-latency updates with minimal network traffic. + +To avoid browser quotas, each `client` instance uses at most one SSE connection at a time. ```ts import { createClient } from "@ponder/client"; @@ -107,6 +226,9 @@ const { unsubscribe } = client.live( (db) => db.select().from(schema.account), (result) => { // ... + }, + (error) => { + // ... } ); ``` @@ -126,6 +248,9 @@ const mainnetStatus = client.db .where(eq(status.chainId, 1)); ``` -### `@ponder/react` +## Example projects + +These example apps demonstrate how to use `@ponder/client`. -Coming soon. +- [**Basic**](https://github.com/ponder-sh/ponder/blob/v0.9/examples/with-client/client/src/index.ts) +- [**NextJs**](https://github.com/ponder-sh/ponder/blob/v0.9/examples/with-nextjs/frontend/src/hooks/useDeposits.ts) diff --git a/examples/with-client/client/package.json b/examples/with-client/client/package.json index 225b9b536..54e951a68 100644 --- a/examples/with-client/client/package.json +++ b/examples/with-client/client/package.json @@ -10,6 +10,7 @@ "@ponder/client": "workspace:*" }, "devDependencies": { - "@types/pg": "^8.10.9" + "@types/pg": "^8.10.9", + "tsx": "^4.19.2" } } diff --git a/examples/with-client/client/src/index.ts b/examples/with-client/client/src/index.ts index 90608625d..1e6a4d682 100644 --- a/examples/with-client/client/src/index.ts +++ b/examples/with-client/client/src/index.ts @@ -1,12 +1,11 @@ -import { createClient } from "@ponder/client"; +import { createClient, sum } from "@ponder/client"; import * as schema from "../../ponder/ponder.schema"; const client = createClient("http://localhost:42069", { schema }); -const response = await client.db - // ^? - .select() +const result = await client.db + .select({ sum: sum(schema.account.balance) }) .from(schema.account) - .limit(10); + .execute(); -console.log({ response }); +console.log(result); diff --git a/examples/with-client/ponder/abis/erc20ABI.ts b/examples/with-client/ponder/abis/erc20ABI.ts deleted file mode 100644 index 94cbc6a33..000000000 --- a/examples/with-client/ponder/abis/erc20ABI.ts +++ /dev/null @@ -1,147 +0,0 @@ -export const erc20ABI = [ - { - stateMutability: "view", - type: "function", - inputs: [], - name: "DOMAIN_SEPARATOR", - outputs: [{ name: "", internalType: "bytes32", type: "bytes32" }], - }, - { - stateMutability: "view", - type: "function", - inputs: [ - { name: "", internalType: "address", type: "address" }, - { name: "", internalType: "address", type: "address" }, - ], - name: "allowance", - outputs: [{ name: "", internalType: "uint256", type: "uint256" }], - }, - { - stateMutability: "nonpayable", - type: "function", - inputs: [ - { name: "spender", internalType: "address", type: "address" }, - { name: "amount", internalType: "uint256", type: "uint256" }, - ], - name: "approve", - outputs: [{ name: "", internalType: "bool", type: "bool" }], - }, - { - stateMutability: "view", - type: "function", - inputs: [{ name: "", internalType: "address", type: "address" }], - name: "balanceOf", - outputs: [{ name: "", internalType: "uint256", type: "uint256" }], - }, - { - stateMutability: "view", - type: "function", - inputs: [], - name: "decimals", - outputs: [{ name: "", internalType: "uint8", type: "uint8" }], - }, - { - stateMutability: "view", - type: "function", - inputs: [], - name: "name", - outputs: [{ name: "", internalType: "string", type: "string" }], - }, - { - stateMutability: "view", - type: "function", - inputs: [{ name: "", internalType: "address", type: "address" }], - name: "nonces", - outputs: [{ name: "", internalType: "uint256", type: "uint256" }], - }, - { - stateMutability: "nonpayable", - type: "function", - inputs: [ - { name: "owner", internalType: "address", type: "address" }, - { name: "spender", internalType: "address", type: "address" }, - { name: "value", internalType: "uint256", type: "uint256" }, - { name: "deadline", internalType: "uint256", type: "uint256" }, - { name: "v", internalType: "uint8", type: "uint8" }, - { name: "r", internalType: "bytes32", type: "bytes32" }, - { name: "s", internalType: "bytes32", type: "bytes32" }, - ], - name: "permit", - outputs: [], - }, - { - stateMutability: "view", - type: "function", - inputs: [], - name: "symbol", - outputs: [{ name: "", internalType: "string", type: "string" }], - }, - { - stateMutability: "view", - type: "function", - inputs: [], - name: "totalSupply", - outputs: [{ name: "", internalType: "uint256", type: "uint256" }], - }, - { - stateMutability: "nonpayable", - type: "function", - inputs: [ - { name: "to", internalType: "address", type: "address" }, - { name: "amount", internalType: "uint256", type: "uint256" }, - ], - name: "transfer", - outputs: [{ name: "", internalType: "bool", type: "bool" }], - }, - { - stateMutability: "nonpayable", - type: "function", - inputs: [ - { name: "from", internalType: "address", type: "address" }, - { name: "to", internalType: "address", type: "address" }, - { name: "amount", internalType: "uint256", type: "uint256" }, - ], - name: "transferFrom", - outputs: [{ name: "", internalType: "bool", type: "bool" }], - }, - { - type: "event", - anonymous: false, - inputs: [ - { - name: "owner", - internalType: "address", - type: "address", - indexed: true, - }, - { - name: "spender", - internalType: "address", - type: "address", - indexed: true, - }, - { - name: "amount", - internalType: "uint256", - type: "uint256", - indexed: false, - }, - ], - name: "Approval", - }, - { - type: "event", - anonymous: false, - inputs: [ - { name: "from", internalType: "address", type: "address", indexed: true }, - { name: "to", internalType: "address", type: "address", indexed: true }, - { - name: "amount", - internalType: "uint256", - type: "uint256", - indexed: false, - }, - ], - name: "Transfer", - }, -] as const; diff --git a/examples/with-client/ponder/abis/weth9Abi.ts b/examples/with-client/ponder/abis/weth9Abi.ts new file mode 100644 index 000000000..94f0a86dd --- /dev/null +++ b/examples/with-client/ponder/abis/weth9Abi.ts @@ -0,0 +1,153 @@ +export const weth9Abi = [ + { + constant: true, + payable: false, + stateMutability: "view", + type: "function", + inputs: [], + name: "name", + outputs: [{ name: "", type: "string" }], + }, + { + constant: false, + payable: false, + stateMutability: "nonpayable", + type: "function", + inputs: [ + { name: "guy", type: "address" }, + { name: "wad", type: "uint256" }, + ], + name: "approve", + outputs: [{ name: "", type: "bool" }], + }, + { + constant: true, + payable: false, + stateMutability: "view", + type: "function", + inputs: [], + name: "totalSupply", + outputs: [{ name: "", type: "uint256" }], + }, + { + constant: false, + payable: false, + stateMutability: "nonpayable", + type: "function", + inputs: [ + { name: "src", type: "address" }, + { name: "dst", type: "address" }, + { name: "wad", type: "uint256" }, + ], + name: "transferFrom", + outputs: [{ name: "", type: "bool" }], + }, + { + constant: false, + payable: false, + stateMutability: "nonpayable", + type: "function", + inputs: [{ name: "wad", type: "uint256" }], + name: "withdraw", + outputs: [], + }, + { + constant: true, + payable: false, + stateMutability: "view", + type: "function", + inputs: [], + name: "decimals", + outputs: [{ name: "", type: "uint8" }], + }, + { + constant: true, + payable: false, + stateMutability: "view", + type: "function", + inputs: [{ name: "", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + }, + { + constant: true, + payable: false, + stateMutability: "view", + type: "function", + inputs: [], + name: "symbol", + outputs: [{ name: "", type: "string" }], + }, + { + constant: false, + payable: false, + stateMutability: "nonpayable", + type: "function", + inputs: [ + { name: "dst", type: "address" }, + { name: "wad", type: "uint256" }, + ], + name: "transfer", + outputs: [{ name: "", type: "bool" }], + }, + { + constant: false, + payable: true, + stateMutability: "payable", + type: "function", + inputs: [], + name: "deposit", + outputs: [], + }, + { + constant: true, + payable: false, + stateMutability: "view", + type: "function", + inputs: [ + { name: "", type: "address" }, + { name: "", type: "address" }, + ], + name: "allowance", + outputs: [{ name: "", type: "uint256" }], + }, + { payable: true, stateMutability: "payable", type: "fallback" }, + { + type: "event", + anonymous: false, + inputs: [ + { name: "src", type: "address", indexed: true }, + { name: "guy", type: "address", indexed: true }, + { name: "wad", type: "uint256", indexed: false }, + ], + name: "Approval", + }, + { + type: "event", + anonymous: false, + inputs: [ + { name: "src", type: "address", indexed: true }, + { name: "dst", type: "address", indexed: true }, + { name: "wad", type: "uint256", indexed: false }, + ], + name: "Transfer", + }, + { + type: "event", + anonymous: false, + inputs: [ + { name: "dst", type: "address", indexed: true }, + { name: "wad", type: "uint256", indexed: false }, + ], + name: "Deposit", + }, + { + type: "event", + anonymous: false, + inputs: [ + { name: "src", type: "address", indexed: true }, + { name: "wad", type: "uint256", indexed: false }, + ], + name: "Withdrawal", + }, +] as const; diff --git a/examples/with-client/ponder/ponder.config.ts b/examples/with-client/ponder/ponder.config.ts index 9d14961e7..754ea87aa 100644 --- a/examples/with-client/ponder/ponder.config.ts +++ b/examples/with-client/ponder/ponder.config.ts @@ -1,21 +1,25 @@ import { createConfig } from "ponder"; -import { http } from "viem"; -import { erc20ABI } from "./abis/erc20ABI"; +import { http, createPublicClient } from "viem"; + +import { weth9Abi } from "./abis/weth9Abi"; + +const latestBlockBase = await createPublicClient({ + transport: http(process.env.PONDER_RPC_URL_8453), +}).getBlock(); export default createConfig({ networks: { - mainnet: { - chainId: 1, - transport: http(process.env.PONDER_RPC_URL_1), + base: { + chainId: 8453, + transport: http(process.env.PONDER_RPC_URL_8453), }, }, contracts: { - ERC20: { - network: "mainnet", - abi: erc20ABI, - address: "0x32353A6C91143bfd6C7d363B546e62a9A2489A20", - startBlock: 13142655, - endBlock: 13150000, + weth9: { + abi: weth9Abi, + network: "base", + address: "0x4200000000000000000000000000000000000006", + startBlock: Number(latestBlockBase.number), }, }, }); diff --git a/examples/with-client/ponder/ponder.schema.ts b/examples/with-client/ponder/ponder.schema.ts index f36486fe6..dc0d61b80 100644 --- a/examples/with-client/ponder/ponder.schema.ts +++ b/examples/with-client/ponder/ponder.schema.ts @@ -1,41 +1,6 @@ -import { index, onchainTable, primaryKey } from "ponder"; +import { onchainTable } from "ponder"; export const account = onchainTable("account", (t) => ({ address: t.hex().primaryKey(), balance: t.bigint().notNull(), - isOwner: t.boolean().notNull(), -})); - -export const allowance = onchainTable( - "allowance", - (t) => ({ - owner: t.hex(), - spender: t.hex(), - amount: t.bigint().notNull(), - }), - (table) => ({ - pk: primaryKey({ columns: [table.owner, table.spender] }), - }), -); - -export const transferEvent = onchainTable( - "transfer_event", - (t) => ({ - id: t.text().primaryKey(), - amount: t.bigint().notNull(), - timestamp: t.integer().notNull(), - from: t.hex().notNull(), - to: t.hex().notNull(), - }), - (table) => ({ - fromIdx: index("from_index").on(table.from), - }), -); - -export const approvalEvent = onchainTable("approval_event", (t) => ({ - id: t.text().primaryKey(), - amount: t.bigint().notNull(), - timestamp: t.integer().notNull(), - owner: t.hex().notNull(), - spender: t.hex().notNull(), })); diff --git a/examples/with-client/ponder/src/index.ts b/examples/with-client/ponder/src/index.ts index 1cb307ed6..191095578 100644 --- a/examples/with-client/ponder/src/index.ts +++ b/examples/with-client/ponder/src/index.ts @@ -1,53 +1,9 @@ import { ponder } from "ponder:registry"; -import { - account, - allowance, - approvalEvent, - transferEvent, -} from "ponder:schema"; +import { account } from "ponder:schema"; -ponder.on("ERC20:Transfer", async ({ event, context }) => { +ponder.on("weth9:Deposit", async ({ event, context }) => { await context.db .insert(account) - .values({ address: event.args.from, balance: 0n, isOwner: false }) - .onConflictDoUpdate((row) => ({ - balance: row.balance - event.args.amount, - })); - - await context.db - .insert(account) - .values({ address: event.args.to, balance: 0n, isOwner: false }) - .onConflictDoUpdate((row) => ({ - balance: row.balance + event.args.amount, - })); - - // add row to "transfer_event". - await context.db.insert(transferEvent).values({ - id: event.log.id, - amount: event.args.amount, - timestamp: Number(event.block.timestamp), - from: event.args.from, - to: event.args.to, - }); -}); - -ponder.on("ERC20:Approval", async ({ event, context }) => { - // upsert "allowance". - await context.db - .insert(allowance) - .values({ - spender: event.args.spender, - owner: event.args.owner, - amount: event.args.amount, - }) - .onConflictDoUpdate({ amount: event.args.amount }); - - // add row to "approval_event". - await context.db.insert(approvalEvent).values({ - id: event.log.id, - amount: event.args.amount, - timestamp: Number(event.block.timestamp), - owner: event.args.owner, - spender: event.args.spender, - }); + .values({ address: event.args.dst, balance: event.args.wad }) + .onConflictDoUpdate((row) => ({ balance: row.balance + event.args.wad })); }); diff --git a/examples/with-nextjs/frontend/src/hooks/useDeposits.ts b/examples/with-nextjs/frontend/src/hooks/useDeposits.ts index 0bc6debce..473f051ff 100644 --- a/examples/with-nextjs/frontend/src/hooks/useDeposits.ts +++ b/examples/with-nextjs/frontend/src/hooks/useDeposits.ts @@ -1,5 +1,6 @@ -import { desc } from "@ponder/client"; -import { useQuery } from "@tanstack/react-query"; +import { desc, status } from "@ponder/client"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; import { client, schema } from "./index"; export type Deposit = NonNullable< @@ -7,8 +8,23 @@ export type Deposit = NonNullable< >[number]; export const useDeposits = () => { + const queryClient = useQueryClient(); + const queryKey = ["weth deposits"]; + + useEffect(() => { + const { unsubscribe } = client.live( + (db) => db.select().from(status).limit(10), + () => queryClient.invalidateQueries({ queryKey }), + (error) => { + console.error(error); + }, + ); + return unsubscribe; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryClient]); + return useQuery({ - queryKey: ["weth deposits"], + queryKey, queryFn: () => client.db .select({ @@ -20,6 +36,5 @@ export const useDeposits = () => { .orderBy(desc(schema.depositEvent.timestamp)) .limit(10), staleTime: Number.POSITIVE_INFINITY, - refetchInterval: 1_000, }); }; diff --git a/examples/with-nextjs/frontend/src/pages/index.tsx b/examples/with-nextjs/frontend/src/pages/index.tsx index 8d7249e12..fb70a986c 100644 --- a/examples/with-nextjs/frontend/src/pages/index.tsx +++ b/examples/with-nextjs/frontend/src/pages/index.tsx @@ -38,10 +38,11 @@ function Table({ deposits }: { deposits: Deposit[] }) {

Amount

Timestamp

- {deposits.map(({ account, timestamp, amount }) => ( + {deposits.map(({ account, timestamp, amount }, i) => (
  • + key={`${account}-${timestamp}-${amount}-${i}`} > { + let SSE: typeof EventSource; + if (typeof window === "undefined") { + const undici = await import(/* webpackIgnore: true */ "undici"); + // @ts-ignore + SSE = undici.EventSource; + } else { + SSE = EventSource; + } + + return SSE; +}; + type Schema = { [name: string]: unknown }; type Prettify = { [K in keyof T]: T[K]; } & {}; +type ClientDb = Prettify< + Omit< + PgRemoteDatabase, + | "insert" + | "update" + | "delete" + | "transaction" + | "refreshMaterializedView" + | "_" + > +>; + export type Client = { - db: Prettify< - Omit< - PgRemoteDatabase, - | "insert" - | "update" - | "delete" - | "transaction" - | "refreshMaterializedView" - | "_" - > - >; + /** Query the database. */ + db: ClientDb; + /** Subscribe to live updates. */ + live: ( + query: (db: ClientDb) => Promise, + onData: (result: result) => void, + onError?: (error: Error) => void, + ) => { + unsubscribe: () => void; + }; +}; + +const getUrl = ( + baseUrl: string, + method: "live" | "db" | "status", + query?: QueryWithTypings, +) => { + const url = new URL(`${baseUrl}/client/${method}`); + if (query) { + url.searchParams.set("sql", JSON.stringify(query)); + } + return url; }; /** @@ -39,23 +76,122 @@ export const status = pgTable("_ponder_status", (t) => ({ // @ts-ignore status[Symbol.for("ponder:onchain")] = true; +/** + * Create a client for querying Ponder apps. + * + * @param baseUrl - The URL of the Ponder app. + * @param schema - The schema of the Ponder app. + * + * @example + * ```ts + * import { createClient } from "@ponder/client"; + * import * as schema from "../ponder.schema"; + * + * const client = createClient("https://...", { schema }); + * ``` + */ export const createClient = ( - url: string, + baseUrl: string, { schema }: { schema: schema }, ): Client => { - const db = drizzle( - async (sql, params, method, typings) => { - const result = await fetch(`${url}/client`, { - method: "POST", - body: JSON.stringify({ sql, params, method, typings }), - }); - - return await result.json(); + const noopDatabase = drizzle(() => Promise.resolve({ rows: [] }), { + schema, + casing: "snake_case", + }); + + // @ts-ignore + const dialect: PgDialect = noopDatabase.dialect; + // @ts-ignore + const session: PgSession = noopDatabase.session; + + let sse: EventSource | undefined; + let liveCount = 0; + + const client: Client = { + db: drizzle( + async (sql, params, _, typings) => { + const builtQuery = { sql, params, typings }; + const response = await fetch(getUrl(baseUrl, "db", builtQuery), { + method: "POST", + }); + + if (response.ok === false) { + const error = new Error(await response.text()); + error.stack = undefined; + throw error; + } + + const result = await response.json(); + + return { + ...result, + rows: result.rows.map((row: object) => Object.values(row)), + }; + }, + { schema, casing: "snake_case" }, + ), + live: (_query, onData, onError) => { + // https://github.com/drizzle-team/drizzle-orm/blob/04c91434c7ac10aeb2923efd1d19a7ebf10ea9d4/drizzle-orm/src/pg-core/db.ts#L602-L621 + + const query = _query(noopDatabase) as unknown as SQLWrapper | string; + const sequel = + typeof query === "string" ? sql.raw(query) : query.getSQL(); + const builtQuery = dialect.sqlToQuery(sequel); + + if ( + builtQuery.sql === + 'select "chain_id", "block_number", "block_timestamp", "ready" from "_ponder_status"' && + builtQuery.params.length === 0 + ) { + const addEventListeners = () => { + sse!.addEventListener("message", (event) => { + const data = JSON.parse(event.data) as + | { status: "success"; result: unknown } + | { status: "error"; error: string }; + + if (data.status === "error") { + const error = new Error(data.error); + error.stack = undefined; + onError?.(error); + } else { + // @ts-ignore + onData(data.result); + } + }); + + sse!.addEventListener("error", () => { + onError?.(new Error("server disconnected")); + }); + }; + + liveCount++; + if (sse === undefined) { + getEventSource().then((SSE) => { + sse = new SSE(getUrl(baseUrl, "live")); + addEventListeners(); + }); + } else { + addEventListeners(); + } + + return { + unsubscribe: () => { + if (--liveCount === 0) sse?.close(); + }, + }; + } else { + return client.live( + (db) => db.select().from(status), + () => { + _query(client.db).then(onData).catch(onError); + }, + onError, + ); + } }, - { schema, casing: "snake_case" }, - ); + }; - return { db }; + return client; }; export { diff --git a/packages/core/package.json b/packages/core/package.json index 6f4db1d9b..f3a665b21 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "ponder", - "version": "0.9.0-next.1", + "version": "0.9.0-next.3", "description": "An open-source framework for crypto application backends", "license": "MIT", "type": "module", diff --git a/packages/core/src/_test/setup.ts b/packages/core/src/_test/setup.ts index 1e54f4dc4..38a61472b 100644 --- a/packages/core/src/_test/setup.ts +++ b/packages/core/src/_test/setup.ts @@ -1,5 +1,9 @@ import { buildSchema } from "@/build/schema.js"; -import { type Database, createDatabase } from "@/database/index.js"; +import { + type Database, + type ListenConnection, + createDatabase, +} from "@/database/index.js"; import type { IndexingStore } from "@/indexing-store/index.js"; import { type MetadataStore, @@ -182,6 +186,7 @@ export async function setupDatabaseServices( overrides: Partial = {}, ): Promise<{ database: Database; + listenConnection: ListenConnection; syncStore: SyncStore; indexingStore: IndexingStore<"realtime">; metadataStore: MetadataStore; @@ -205,7 +210,8 @@ export async function setupDatabaseServices( }, }); - await database.prepareNamespace({ buildId: config.buildId }); + await database.migrate({ buildId: config.buildId }); + const listenConnection = await database.getListenConnection(); await database.migrateSync().catch((err) => { console.log(err); @@ -222,10 +228,16 @@ export async function setupDatabaseServices( const metadataStore = getMetadataStore({ database }); - const cleanup = () => database.kill(); + const cleanup = async () => { + if (listenConnection.dialect === "postgres") { + listenConnection.connection.release(); + } + await database.kill(); + }; return { database, + listenConnection, indexingStore, syncStore, metadataStore, diff --git a/packages/core/src/bin/commands/dev.ts b/packages/core/src/bin/commands/dev.ts index d69dc281d..7da865095 100644 --- a/packages/core/src/bin/commands/dev.ts +++ b/packages/core/src/bin/commands/dev.ts @@ -1,7 +1,11 @@ import fs from "node:fs"; import path from "node:path"; import { createBuild } from "@/build/index.js"; -import { type Database, createDatabase } from "@/database/index.js"; +import { + type Database, + type ListenConnection, + createDatabase, +} from "@/database/index.js"; import { createLogger } from "@/internal/logger.js"; import { MetricsService } from "@/internal/metrics.js"; import { buildOptions } from "@/internal/options.js"; @@ -61,6 +65,11 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) { const cleanup = async () => { await indexingCleanupReloadable(); await apiCleanupReloadable(); + if (listenConnection) { + if (listenConnection.dialect === "postgres") { + listenConnection.connection.release(); + } + } if (database) { await database.kill(); } @@ -135,12 +144,6 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) { const [preBuild, schemaBuild] = buildResult1.result; - database = await createDatabase({ - common, - preBuild, - schemaBuild, - }); - const indexingResult = await build.executeIndexingFunctions(); if (indexingResult.status === "error") { buildQueue.add({ @@ -151,7 +154,29 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) { return; } - const apiResult = await build.executeApi({ database }); + const indexingBuildResult = await build.compileIndexing({ + configResult: configResult.result, + schemaResult: schemaResult.result, + indexingResult: indexingResult.result, + }); + + if (indexingBuildResult.status === "error") { + buildQueue.add({ + status: "error", + kind: "indexing", + error: indexingBuildResult.error, + }); + return; + } + + database = await createDatabase({ common, preBuild, schemaBuild }); + await database.migrate(indexingBuildResult.result); + listenConnection = await database.getListenConnection(); + + const apiResult = await build.executeApi({ + database, + listenConnection, + }); if (apiResult.status === "error") { buildQueue.add({ status: "error", @@ -161,26 +186,19 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) { return; } - const buildResult2 = mergeResults([ - await build.compileIndexing({ - configResult: configResult.result, - schemaResult: schemaResult.result, - indexingResult: indexingResult.result, - }), - await build.compileApi({ apiResult: apiResult.result }), - ]); + const apiBuildResult = await build.compileApi({ + apiResult: apiResult.result, + }); - if (buildResult2.status === "error") { + if (apiBuildResult.status === "error") { buildQueue.add({ status: "error", kind: "indexing", - error: buildResult2.error, + error: apiBuildResult.error, }); return; } - const [indexingBuild, apiBuild] = buildResult2.result; - if (isInitialBuild) { isInitialBuild = false; @@ -191,7 +209,7 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) { ...buildPayload({ preBuild, schemaBuild, - indexingBuild, + indexingBuild: indexingBuildResult.result, }), }, }); @@ -201,7 +219,7 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) { common, database, schemaBuild, - indexingBuild, + indexingBuild: indexingBuildResult.result, onFatalError: () => { shutdown({ reason: "Received fatal error", code: 1 }); }, @@ -216,12 +234,15 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) { apiCleanupReloadable = await runServer({ common, database, - apiBuild, + apiBuild: apiBuildResult.result, }); } else { metrics.resetApiMetrics(); - const apiResult = await build.executeApi({ database: database! }); + const apiResult = await build.executeApi({ + database: database!, + listenConnection: listenConnection!, + }); if (apiResult.status === "error") { buildQueue.add({ status: "error", @@ -255,7 +276,9 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) { }); let database: Database | undefined; + let listenConnection: ListenConnection | undefined; + build.initNamespace({ isSchemaRequired: false }); build.initNamespace({ isSchemaRequired: false }); build.startDev({ diff --git a/packages/core/src/bin/commands/serve.ts b/packages/core/src/bin/commands/serve.ts index f60948832..072ea47b0 100644 --- a/packages/core/src/bin/commands/serve.ts +++ b/packages/core/src/bin/commands/serve.ts @@ -89,13 +89,11 @@ export async function serve({ cliOptions }: { cliOptions: CliOptions }) { return cleanup; } - const database = await createDatabase({ - common, - preBuild, - schemaBuild, - }); + const database = await createDatabase({ common, preBuild, schemaBuild }); + // Note: this assumes that the _ponder_status table exists + const listenConnection = await database.getListenConnection(); - const apiResult = await build.executeApi({ database }); + const apiResult = await build.executeApi({ database, listenConnection }); if (apiResult.status === "error") { await shutdown({ reason: "Failed intial build", code: 1 }); return cleanup; @@ -131,6 +129,11 @@ export async function serve({ cliOptions }: { cliOptions: CliOptions }) { cleanupReloadable = async () => { await server.kill(); + + if (listenConnection.dialect === "postgres") { + listenConnection.connection.release(); + } + await database.kill(); }; diff --git a/packages/core/src/bin/commands/start.ts b/packages/core/src/bin/commands/start.ts index 87526a5d1..85878bf8d 100644 --- a/packages/core/src/bin/commands/start.ts +++ b/packages/core/src/bin/commands/start.ts @@ -52,9 +52,16 @@ export async function start({ cliOptions }: { cliOptions: CliOptions }) { const cleanup = async () => { await cleanupReloadable(); await cleanupReloadableServer(); + if (listenConnection) { + if (listenConnection.dialect === "postgres") { + listenConnection.connection.release(); + } + } + if (database) { await database.kill(); } + await telemetry.kill(); }; @@ -90,39 +97,41 @@ export async function start({ cliOptions }: { cliOptions: CliOptions }) { const [preBuild, schemaBuild] = buildResult1.result; - database = await createDatabase({ - common, - preBuild, - schemaBuild, - }); - const indexingResult = await build.executeIndexingFunctions(); if (indexingResult.status === "error") { await shutdown({ reason: "Failed intial build", code: 1 }); return cleanup; } - const apiResult = await build.executeApi({ database }); - if (apiResult.status === "error") { + const indexingBuildResult = await build.compileIndexing({ + configResult: configResult.result, + schemaResult: schemaResult.result, + indexingResult: indexingResult.result, + }); + + if (indexingBuildResult.status === "error") { await shutdown({ reason: "Failed intial build", code: 1 }); return cleanup; } - const buildResult2 = mergeResults([ - await build.compileIndexing({ - configResult: configResult.result, - schemaResult: schemaResult.result, - indexingResult: indexingResult.result, - }), - await build.compileApi({ apiResult: apiResult.result }), - ]); + database = await createDatabase({ common, preBuild, schemaBuild }); + await database.migrate(indexingBuildResult.result); + const listenConnection = await database.getListenConnection(); - if (buildResult2.status === "error") { + const apiResult = await build.executeApi({ database, listenConnection }); + if (apiResult.status === "error") { await shutdown({ reason: "Failed intial build", code: 1 }); return cleanup; } - const [indexingBuild, apiBuild] = buildResult2.result; + const apiBuildResult = await build.compileApi({ + apiResult: apiResult.result, + }); + + if (apiBuildResult.status === "error") { + await shutdown({ reason: "Failed intial build", code: 1 }); + return cleanup; + } await build.kill(); @@ -133,7 +142,7 @@ export async function start({ cliOptions }: { cliOptions: CliOptions }) { ...buildPayload({ preBuild, schemaBuild, - indexingBuild, + indexingBuild: indexingBuildResult.result, }), }, }); @@ -142,7 +151,7 @@ export async function start({ cliOptions }: { cliOptions: CliOptions }) { common, database, schemaBuild, - indexingBuild, + indexingBuild: indexingBuildResult.result, onFatalError: () => { shutdown({ reason: "Received fatal error", code: 1 }); }, @@ -154,7 +163,7 @@ export async function start({ cliOptions }: { cliOptions: CliOptions }) { cleanupReloadableServer = await runServer({ common, database, - apiBuild, + apiBuild: apiBuildResult.result, }); return cleanup; diff --git a/packages/core/src/bin/utils/run.test.ts b/packages/core/src/bin/utils/run.test.ts index 1dc48a704..27e866b35 100644 --- a/packages/core/src/bin/utils/run.test.ts +++ b/packages/core/src/bin/utils/run.test.ts @@ -74,6 +74,8 @@ test("run() setup", async (context) => { }, }); + await database.migrate({ buildId: "buildId" }); + const kill = await run({ common: context.common, database, @@ -137,6 +139,8 @@ test("run() setup error", async (context) => { }, }); + await database.migrate({ buildId: "buildId" }); + indexingFunctions["Erc20:setup"].mockRejectedValue(new Error()); const kill = await run({ diff --git a/packages/core/src/bin/utils/run.ts b/packages/core/src/bin/utils/run.ts index ed9996e29..a1f81cc1d 100644 --- a/packages/core/src/bin/utils/run.ts +++ b/packages/core/src/bin/utils/run.ts @@ -37,11 +37,8 @@ export async function run({ }) { let isKilled = false; - const { checkpoint: initialCheckpoint } = - await database.prepareNamespace(indexingBuild); - + const initialCheckpoint = await database.recoverCheckpoint(); const syncStore = createSyncStore({ common, database }); - const metadataStore = getMetadataStore({ database }); // This can be a long-running operation, so it's best to do it after diff --git a/packages/core/src/build/index.ts b/packages/core/src/build/index.ts index fa80d4eb1..e8e94940f 100644 --- a/packages/core/src/build/index.ts +++ b/packages/core/src/build/index.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import type { CliOptions } from "@/bin/ponder.js"; import type { Config } from "@/config/index.js"; -import type { Database } from "@/database/index.js"; +import type { Database, ListenConnection } from "@/database/index.js"; import type { Common } from "@/internal/common.js"; import { BuildError } from "@/internal/errors.js"; import type { @@ -35,6 +35,7 @@ import { parseViteNodeError } from "./stacktrace.js"; declare global { var PONDER_DATABASE_SCHEMA: string | undefined; var PONDER_READONLY_DB: Drizzle; + var PONDER_LISTEN_CONNECTION: ListenConnection; } const BUILD_ID_VERSION = "1"; @@ -52,7 +53,10 @@ export type Build = { executeConfig: () => Promise; executeSchema: () => Promise; executeIndexingFunctions: () => Promise; - executeApi: (params: { database: Database }) => Promise; + executeApi: (params: { + database: Database; + listenConnection: ListenConnection; + }) => Promise; preCompile: (params: { config: Config }) => Result; compileSchema: (params: { schema: Schema }) => Result; compileIndexing: (params: { @@ -292,8 +296,9 @@ export const createBuild = async ({ }, }; }, - async executeApi({ database }): Promise { + async executeApi({ database, listenConnection }): Promise { global.PONDER_READONLY_DB = database.qb.drizzleReadonly; + global.PONDER_LISTEN_CONNECTION = listenConnection; if (!fs.existsSync(common.options.apiFile)) { const error = new BuildError( diff --git a/packages/core/src/client/index.test.ts b/packages/core/src/client/index.test.ts new file mode 100644 index 000000000..091104d48 --- /dev/null +++ b/packages/core/src/client/index.test.ts @@ -0,0 +1,64 @@ +import { + setupCommon, + setupDatabaseServices, + setupIsolatedDatabase, +} from "@/_test/setup.js"; +import { onchainTable } from "@/drizzle/onchain.js"; +import type { QueryWithTypings } from "drizzle-orm"; +import { Hono } from "hono"; +import { beforeEach, expect, test } from "vitest"; +import { client } from "./index.js"; + +beforeEach(setupCommon); +beforeEach(setupIsolatedDatabase); + +const queryToParams = (query: QueryWithTypings) => + new URLSearchParams({ sql: JSON.stringify(query) }); + +test("client.db", async (context) => { + const account = onchainTable("account", (p) => ({ + address: p.hex().primaryKey(), + balance: p.bigint(), + })); + + const { database, cleanup, listenConnection } = await setupDatabaseServices( + context, + { + schema: { account }, + }, + ); + global.PONDER_LISTEN_CONNECTION = listenConnection; + + const app = new Hono().use(client({ db: database.qb.drizzleReadonly })); + + const query = { + sql: "SELECT * FROM account", + params: [], + }; + + const response = await app.request(`/client/db?${queryToParams(query)}`); + expect(response.status).toBe(200); + const result = await response.json(); + expect(result.rows).toStrictEqual([]); + + await cleanup(); +}); + +test("client.db error", async (context) => { + const { database, cleanup, listenConnection } = + await setupDatabaseServices(context); + global.PONDER_LISTEN_CONNECTION = listenConnection; + + const app = new Hono().use(client({ db: database.qb.drizzleReadonly })); + + const query = { + sql: "SELECT * FROM account", + params: [], + }; + + const response = await app.request(`/client/db?${queryToParams(query)}`); + expect(response.status).toBe(500); + expect(await response.text()).toContain('relation "account" does not exist'); + + await cleanup(); +}); diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index b01486d07..b06698643 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -1,31 +1,113 @@ import type { Schema } from "@/internal/types.js"; import type { ReadonlyDrizzle } from "@/types/db.js"; +import { promiseWithResolvers } from "@ponder/common"; +import type { QueryWithTypings } from "drizzle-orm"; +import { type PgSession, pgTable } from "drizzle-orm/pg-core"; import { createMiddleware } from "hono/factory"; +import { streamSSE } from "hono/streaming"; +const status = pgTable("_ponder_status", (t) => ({ + chainId: t.bigint({ mode: "number" }).primaryKey(), + blockNumber: t.bigint({ mode: "number" }), + blockTimestamp: t.bigint({ mode: "number" }), + ready: t.boolean().notNull(), +})); + +/** + * Middleware for `@ponder/client`. + * + * @param db - Drizzle database instance + * + * @example + * ```ts + * import { db } from "ponder:api"; + * import { Hono } from "hono"; + * import { client } from "ponder"; + * + * const app = new Hono(); + * + * app.use(client({ db })); + * + * export default app; + * ``` + */ export const client = ({ db }: { db: ReadonlyDrizzle }) => { + // @ts-ignore + const session: PgSession = db._.session; + const listenConnection = global.PONDER_LISTEN_CONNECTION; + let statusResolver = promiseWithResolvers<(typeof status.$inferSelect)[]>(); + + let queryPromise: Promise; + + const channel = `${global.PONDER_DATABASE_SCHEMA}_status_channel`; + + if (listenConnection.dialect === "pglite") { + queryPromise = listenConnection.connection.query(`LISTEN ${channel}`); + + listenConnection.connection.onNotification(async () => { + const result = await db.select().from(status); + statusResolver.resolve(result); + statusResolver = promiseWithResolvers(); + }); + } else { + queryPromise = listenConnection.connection.query(`LISTEN ${channel}`); + + listenConnection.connection.on("notification", async () => { + const result = await db.select().from(status); + statusResolver.resolve(result); + statusResolver = promiseWithResolvers(); + }); + } + return createMiddleware(async (c, next) => { - if (c.req.path !== "/client") { - return next(); + if (c.req.path === "/client/db") { + const queryString = c.req.query("sql"); + if (queryString === undefined) { + return c.text('Missing "sql" query parameter', 400); + } + const query = JSON.parse(queryString) as QueryWithTypings; + + try { + const result = await session + .prepareQuery(query, undefined, undefined, false) + .execute(); + + return c.json(result as object); + } catch (error) { + return c.text((error as Error).message, 500); + } + } + + if (c.req.path === "/client/live") { + // TODO(kyle) live queries only availble in realtime mode + + c.header("Content-Type", "text/event-stream"); + c.header("Cache-Control", "no-cache"); + c.header("Connection", "keep-alive"); + + await queryPromise; + + let statusResult = await db.select().from(status); + + return streamSSE(c, async (stream) => { + while (stream.closed === false) { + try { + await stream.writeSSE({ + data: JSON.stringify({ status: "success", result: statusResult }), + }); + } catch (error) { + await stream.writeSSE({ + data: JSON.stringify({ + status: "error", + error: (error as Error).message, + }), + }); + } + statusResult = await statusResolver.promise; + } + }); } - const body = await c.req.json(); - - // @ts-ignore - const res = await db._.session - .prepareQuery( - { - sql: body.sql, - params: body.params, - // @ts-ignore - typings: body.typings, - }, - undefined, - undefined, - body.method === "all", - ) - .execute(); - - // @ts-ignore - return c.json({ rows: res.rows.map((row) => Object.values(row)) }); + return next(); }); }; diff --git a/packages/core/src/database/index.test.ts b/packages/core/src/database/index.test.ts index 180db0426..ce3f57e8b 100644 --- a/packages/core/src/database/index.test.ts +++ b/packages/core/src/database/index.test.ts @@ -48,7 +48,7 @@ test("createDatabase() readonly", async (context) => { }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); const error = await database.qb.drizzleReadonly .insert(account) @@ -65,9 +65,9 @@ test("createDatabase() readonly", async (context) => { }); test("createDatabase() search path", async (context) => { - // create table in "ponder" schema + // create table in "Ponder" schema - const schemaAccount = pgSchema("ponder").table("account", { + const schemaAccount = pgSchema("Ponder").table("account", { address: hex().primaryKey(), balance: bigint(), }); @@ -76,7 +76,7 @@ test("createDatabase() search path", async (context) => { common: context.common, preBuild: { databaseConfig: context.databaseConfig, - namespace: "ponder", + namespace: "Ponder", }, schemaBuild: { schema: { account: schemaAccount }, @@ -84,10 +84,10 @@ test("createDatabase() search path", async (context) => { .statements, }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); // using bare "account" will leave schema empty, and the search_path - // will then use the "ponder" schema + // will then use the "Ponder" schema const rows = await database.qb.drizzleReadonly.select().from(account); @@ -96,7 +96,7 @@ test("createDatabase() search path", async (context) => { await database.kill(); }); -test("prepareNamespace() succeeds with empty schema", async (context) => { +test("migrate() succeeds with empty schema", async (context) => { const database = await createDatabase({ common: context.common, preBuild: { @@ -109,9 +109,7 @@ test("prepareNamespace() succeeds with empty schema", async (context) => { }, }); - const { checkpoint } = await database.prepareNamespace({ buildId: "abc" }); - - expect(checkpoint).toMatchObject(encodeCheckpoint(zeroCheckpoint)); + await database.migrate({ buildId: "abc" }); const tableNames = await getUserTableNames(database, "public"); expect(tableNames).toContain("account"); @@ -129,43 +127,55 @@ test("prepareNamespace() succeeds with empty schema", async (context) => { await database.kill(); }); -test("prepareNamespace() throws with schema used", async (context) => { - const database = await createDatabase({ - common: context.common, - preBuild: { - databaseConfig: context.databaseConfig, - namespace: "public", - }, - schemaBuild: { - schema: { account }, - statements: buildSchema({ schema: { account } }).statements, - }, - }); - await database.prepareNamespace({ buildId: "abc" }); - await database.kill(); +test("migrate() with empty schema creates tables and enums", async (context) => { + const mood = onchainEnum("mood", ["sad", "happy"]); - const databaseTwo = await createDatabase({ + const kyle = onchainTable("kyle", (p) => ({ + age: p.integer().primaryKey(), + mood: mood().notNull(), + })); + + const user = onchainTable( + "table", + (p) => ({ + name: p.text(), + age: p.integer(), + address: p.hex(), + }), + (table) => ({ + primaryKeys: primaryKey({ columns: [table.name, table.address] }), + }), + ); + + const database = await createDatabase({ common: context.common, preBuild: { databaseConfig: context.databaseConfig, namespace: "public", }, schemaBuild: { - schema: { account }, - statements: buildSchema({ schema: { account } }).statements, + schema: { account, kyle, mood, user }, + statements: buildSchema({ schema: { account, kyle, mood, user } }) + .statements, }, }); - const error = await databaseTwo - .prepareNamespace({ buildId: "def" }) - .catch((err) => err); + await database.migrate({ buildId: "abc" }); - expect(error).toBeDefined(); + const tableNames = await getUserTableNames(database, "public"); + expect(tableNames).toContain("account"); + expect(tableNames).toContain("_reorg__account"); + expect(tableNames).toContain("kyle"); + expect(tableNames).toContain("_reorg__kyle"); + expect(tableNames).toContain("kyle"); + expect(tableNames).toContain("_reorg__kyle"); + expect(tableNames).toContain("_ponder_meta"); - await databaseTwo.kill(); + await database.unlock(); + await database.kill(); }); -test("prepareNamespace() succeeds with crash recovery", async (context) => { +test("migrate() throws with schema used", async (context) => { const database = await createDatabase({ common: context.common, preBuild: { @@ -177,14 +187,7 @@ test("prepareNamespace() succeeds with crash recovery", async (context) => { statements: buildSchema({ schema: { account } }).statements, }, }); - - await database.prepareNamespace({ buildId: "abc" }); - - await database.finalize({ - checkpoint: createCheckpoint(10), - }); - - await database.unlock(); + await database.migrate({ buildId: "abc" }); await database.kill(); const databaseTwo = await createDatabase({ @@ -199,28 +202,22 @@ test("prepareNamespace() succeeds with crash recovery", async (context) => { }, }); - const { checkpoint } = await databaseTwo.prepareNamespace({ buildId: "abc" }); - - expect(checkpoint).toMatchObject(createCheckpoint(10)); - - const metadata = await databaseTwo.qb.internal - .selectFrom("_ponder_meta") - .selectAll() - .execute(); - - expect(metadata).toHaveLength(1); + const error = await databaseTwo + .migrate({ buildId: "def" }) + .catch((err) => err); - const tableNames = await getUserTableNames(databaseTwo, "public"); - expect(tableNames).toContain("account"); - expect(tableNames).toContain("_reorg__account"); - expect(tableNames).toContain("_ponder_meta"); + expect(error).toBeDefined(); await databaseTwo.kill(); }); -test("prepareNamespace() succeeds with crash recovery after waiting for lock", async (context) => { - context.common.options.databaseHeartbeatInterval = 750; - context.common.options.databaseHeartbeatTimeout = 500; +// PGlite not being able to concurrently connect to the same database from two different clients +// makes this test impossible. +test("migrate() throws with schema used after waiting for lock", async (context) => { + if (context.databaseConfig.kind !== "postgres") return; + + context.common.options.databaseHeartbeatInterval = 250; + context.common.options.databaseHeartbeatTimeout = 1000; const database = await createDatabase({ common: context.common, @@ -233,7 +230,7 @@ test("prepareNamespace() succeeds with crash recovery after waiting for lock", a statements: buildSchema({ schema: { account } }).statements, }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); await database.finalize({ checkpoint: createCheckpoint(10) }); const databaseTwo = await createDatabase({ @@ -248,23 +245,17 @@ test("prepareNamespace() succeeds with crash recovery after waiting for lock", a }, }); - const { checkpoint } = await databaseTwo.prepareNamespace({ buildId: "abc" }); + const error = await databaseTwo + .migrate({ buildId: "abc" }) + .catch((err) => err); - expect(checkpoint).toMatchObject(createCheckpoint(10)); + expect(error).toBeDefined(); - await database.unlock(); await database.kill(); await databaseTwo.kill(); }); -// PGlite not being able to concurrently connect to the same database from two different clients -// makes this test impossible. -test("prepareNamespace() throws with schema used after waiting for lock", async (context) => { - if (context.databaseConfig.kind !== "postgres") return; - - context.common.options.databaseHeartbeatInterval = 250; - context.common.options.databaseHeartbeatTimeout = 1000; - +test("migrate() succeeds with crash recovery", async (context) => { const database = await createDatabase({ common: context.common, preBuild: { @@ -276,8 +267,15 @@ test("prepareNamespace() throws with schema used after waiting for lock", async statements: buildSchema({ schema: { account } }).statements, }, }); - await database.prepareNamespace({ buildId: "abc" }); - await database.finalize({ checkpoint: createCheckpoint(10) }); + + await database.migrate({ buildId: "abc" }); + + await database.finalize({ + checkpoint: createCheckpoint(10), + }); + + await database.unlock(); + await database.kill(); const databaseTwo = await createDatabase({ common: context.common, @@ -291,35 +289,26 @@ test("prepareNamespace() throws with schema used after waiting for lock", async }, }); - const error = await databaseTwo - .prepareNamespace({ buildId: "abc" }) - .catch((err) => err); + await databaseTwo.migrate({ buildId: "abc" }); - expect(error).toBeDefined(); + const metadata = await databaseTwo.qb.internal + .selectFrom("_ponder_meta") + .selectAll() + .execute(); - await database.kill(); - await databaseTwo.kill(); -}); + expect(metadata).toHaveLength(1); -test("prepareNamespace() with empty schema creates tables and enums", async (context) => { - const mood = onchainEnum("mood", ["sad", "happy"]); + const tableNames = await getUserTableNames(databaseTwo, "public"); + expect(tableNames).toContain("account"); + expect(tableNames).toContain("_reorg__account"); + expect(tableNames).toContain("_ponder_meta"); - const kyle = onchainTable("kyle", (p) => ({ - age: p.integer().primaryKey(), - mood: mood().notNull(), - })); + await databaseTwo.kill(); +}); - const user = onchainTable( - "table", - (p) => ({ - name: p.text(), - age: p.integer(), - address: p.hex(), - }), - (table) => ({ - primaryKeys: primaryKey({ columns: [table.name, table.address] }), - }), - ); +test("migrate() succeeds with crash recovery after waiting for lock", async (context) => { + context.common.options.databaseHeartbeatInterval = 750; + context.common.options.databaseHeartbeatTimeout = 500; const database = await createDatabase({ common: context.common, @@ -328,28 +317,33 @@ test("prepareNamespace() with empty schema creates tables and enums", async (con namespace: "public", }, schemaBuild: { - schema: { account, kyle, mood, user }, - statements: buildSchema({ schema: { account, kyle, mood, user } }) - .statements, + schema: { account }, + statements: buildSchema({ schema: { account } }).statements, }, }); + await database.migrate({ buildId: "abc" }); + await database.finalize({ checkpoint: createCheckpoint(10) }); - await database.prepareNamespace({ buildId: "abc" }); + const databaseTwo = await createDatabase({ + common: context.common, + preBuild: { + databaseConfig: context.databaseConfig, + namespace: "public", + }, + schemaBuild: { + schema: { account }, + statements: buildSchema({ schema: { account } }).statements, + }, + }); - const tableNames = await getUserTableNames(database, "public"); - expect(tableNames).toContain("account"); - expect(tableNames).toContain("_reorg__account"); - expect(tableNames).toContain("kyle"); - expect(tableNames).toContain("_reorg__kyle"); - expect(tableNames).toContain("kyle"); - expect(tableNames).toContain("_reorg__kyle"); - expect(tableNames).toContain("_ponder_meta"); + await databaseTwo.migrate({ buildId: "abc" }); await database.unlock(); await database.kill(); + await databaseTwo.kill(); }); -test("prepareNamespace() with crash recovery reverts rows", async (context) => { +test("recoverCheckpoint() with crash recovery reverts rows", async (context) => { const database = await createDatabase({ common: context.common, preBuild: { @@ -362,7 +356,7 @@ test("prepareNamespace() with crash recovery reverts rows", async (context) => { }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); // setup tables, reorg tables, and metadata checkpoint @@ -409,7 +403,8 @@ test("prepareNamespace() with crash recovery reverts rows", async (context) => { }, }); - const { checkpoint } = await databaseTwo.prepareNamespace({ buildId: "abc" }); + await databaseTwo.migrate({ buildId: "abc" }); + const checkpoint = await databaseTwo.recoverCheckpoint(); expect(checkpoint).toMatchObject(createCheckpoint(10)); @@ -430,7 +425,7 @@ test("prepareNamespace() with crash recovery reverts rows", async (context) => { await databaseTwo.kill(); }); -test("prepareNamespace() with crash recovery drops indexes and triggers", async (context) => { +test("recoverCheckpoint() with crash recovery drops indexes and triggers", async (context) => { const account = onchainTable( "account", (p) => ({ @@ -454,7 +449,7 @@ test("prepareNamespace() with crash recovery drops indexes and triggers", async }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); await database.finalize({ checkpoint: createCheckpoint(10), @@ -477,7 +472,8 @@ test("prepareNamespace() with crash recovery drops indexes and triggers", async }, }); - await databaseTwo.prepareNamespace({ buildId: "abc" }); + await databaseTwo.migrate({ buildId: "abc" }); + await databaseTwo.recoverCheckpoint(); const indexNames = await getUserIndexNames(databaseTwo, "public", "account"); @@ -502,7 +498,7 @@ test("heartbeat updates the heartbeat_at value", async (context) => { }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); const row = await database.qb.internal .selectFrom("_ponder_meta") @@ -541,7 +537,7 @@ test("finalize()", async (context) => { }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); // setup tables, reorg tables, and metadata checkpoint @@ -613,7 +609,7 @@ test("unlock()", async (context) => { }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); await database.unlock(); await database.kill(); @@ -665,7 +661,7 @@ test("createIndexes()", async (context) => { }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); await database.createIndexes(); const indexNames = await getUserIndexNames(database, "public", "account"); @@ -688,7 +684,7 @@ test("createTriggers()", async (context) => { }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); await database.createTriggers(); const indexingStore = createRealtimeIndexingStore({ @@ -733,7 +729,7 @@ test("createTriggers() duplicate", async (context) => { }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); await database.createTriggers(); await database.createTriggers(); @@ -754,7 +750,7 @@ test("complete()", async (context) => { }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); await database.createTriggers(); const indexingStore = createRealtimeIndexingStore({ @@ -802,7 +798,7 @@ test("revert()", async (context) => { }, }); - await database.prepareNamespace({ buildId: "abc" }); + await database.migrate({ buildId: "abc" }); // setup tables, reorg tables, and metadata checkpoint diff --git a/packages/core/src/database/index.ts b/packages/core/src/database/index.ts index a7b28ed90..82d6c8e9b 100644 --- a/packages/core/src/database/index.ts +++ b/packages/core/src/database/index.ts @@ -39,7 +39,7 @@ import { sql, } from "kysely"; import { KyselyPGlite } from "kysely-pglite"; -import type { Pool } from "pg"; +import type { Pool, PoolClient } from "pg"; import parse from "pg-connection-string"; import prometheus from "prom-client"; @@ -52,20 +52,14 @@ export type Database = { }, fn: () => Promise, ) => Promise; + /** Migrate the `ponder_sync` schema. */ migrateSync(): Promise; - /** - * Prepare the database environment for a Ponder app. - * - * The core logic in this function reads the schema where the new - * app will live, and decides what to do. Metadata is stored in the - * "_ponder_meta" table, and any residual entries in this table are - * used to determine what action this function will take. - * - * @returns The progress checkpoint that that app should start from. - */ - prepareNamespace(args: Pick): Promise<{ - checkpoint: string; - }>; + /** Migrate the user schema. */ + migrate({ buildId }: Pick): Promise; + /** ... */ + getListenConnection(): Promise; + /** Determine the app checkpoint , possibly reverting unfinalized rows. */ + recoverCheckpoint(): Promise; createIndexes(): Promise; createTriggers(): Promise; removeTriggers(): Promise; @@ -76,6 +70,10 @@ export type Database = { kill(): Promise; }; +export type ListenConnection = + | { dialect: "pglite"; connection: PGlite } + | { dialect: "postgres"; connection: PoolClient }; + export type PonderApp = { is_locked: 0 | 1; is_dev: 0 | 1; @@ -140,7 +138,7 @@ export const createDatabase = async ({ let isKilled = false; //////// - // Create drivers and orms + // Create schema, drivers, roles, and query builders //////// let driver: PGliteDriver | PostgresDriver; @@ -148,6 +146,11 @@ export const createDatabase = async ({ const dialect = preBuild.databaseConfig.kind; + common.logger.info({ + service: "database", + msg: `Using database schema '${preBuild.namespace}'`, + }); + if (dialect === "pglite" || dialect === "pglite_test") { driver = { instance: @@ -261,6 +264,9 @@ export const createDatabase = async ({ await internal.query( `GRANT USAGE ON SCHEMA "${preBuild.namespace}" TO "${role}"`, ); + await internal.query( + `GRANT SELECT ON ALL TABLES IN SCHEMA "${preBuild.namespace}" TO "${role}"`, + ); await internal.query( `ALTER DEFAULT PRIVILEGES IN SCHEMA "${preBuild.namespace}" GRANT SELECT ON TABLES TO "${role}"`, ); @@ -302,7 +308,7 @@ export const createDatabase = async ({ }, common.logger, ), - }; + } as PostgresDriver; qb = { internal: new Kysely({ @@ -494,6 +500,8 @@ export const createDatabase = async ({ }); }; + let checkpoint: string | undefined; + const database = { qb, // @ts-ignore @@ -587,12 +595,7 @@ export const createDatabase = async ({ if (error) throw error; }); }, - async prepareNamespace({ buildId }) { - common.logger.info({ - service: "database", - msg: `Using database schema '${preBuild.namespace}'`, - }); - + async migrate({ buildId }) { //////// // Migrate //////// @@ -761,15 +764,6 @@ export const createDatabase = async ({ // 0.9 migration - await qb.internal.schema - .createTable("_ponder_status") - .addColumn("chain_id", "bigint", (col) => col.primaryKey()) - .addColumn("block_number", "bigint") - .addColumn("block_timestamp", "bigint") - .addColumn("ready", "boolean", (col) => col.notNull()) - .ifNotExists() - .execute(); - if (hasPonderMetaTable) { await qb.internal .deleteFrom("_ponder_meta") @@ -778,51 +772,41 @@ export const createDatabase = async ({ .execute(); } - await this.wrap({ method: "setup" }, async () => { - // Create "_ponder_meta" table if it doesn't exist + await this.wrap({ method: "migrate" }, async () => { await qb.internal.schema .createTable("_ponder_meta") .addColumn("key", "text", (col) => col.primaryKey()) .addColumn("value", "jsonb") .ifNotExists() .execute(); + + await qb.internal.schema + .createTable("_ponder_status") + .addColumn("chain_id", "bigint", (col) => col.primaryKey()) + .addColumn("block_number", "bigint") + .addColumn("block_timestamp", "bigint") + .addColumn("ready", "boolean", (col) => col.notNull()) + .ifNotExists() + .execute(); }); const attempt = () => - this.wrap({ method: "setup" }, () => + this.wrap({ method: "migrate" }, () => qb.internal.transaction().execute(async (tx) => { - const previousApp = await tx - .selectFrom("_ponder_meta") - .where("key", "=", "app") - .select("value") - .executeTakeFirst() - .then((row) => row?.value as PonderApp | undefined); - - const newApp = { - is_locked: 1, - is_dev: common.options.command === "dev" ? 1 : 0, - heartbeat_at: Date.now(), - build_id: buildId, - checkpoint: encodeCheckpoint(zeroCheckpoint), - table_names: getTableNames(schemaBuild.schema).map( - (tableName) => tableName.sql, - ), - } satisfies PonderApp; - - const createEnums = async () => { + const createTables = async () => { for ( let i = 0; - i < schemaBuild.statements.enums.sql.length; + i < schemaBuild.statements.tables.sql.length; i++ ) { await sql - .raw(schemaBuild.statements.enums.sql[i]!) + .raw(schemaBuild.statements.tables.sql[i]!) .execute(tx) .catch((_error) => { const error = _error as Error; if (!error.message.includes("already exists")) throw error; const e = new NonRetryableError( - `Unable to create enum '${preBuild.namespace}'.'${schemaBuild.statements.enums.json[i]!.name}' because an enum with that name already exists.`, + `Unable to create table '${preBuild.namespace}'.'${schemaBuild.statements.tables.json[i]!.tableName}' because a table with that name already exists.`, ); e.stack = undefined; throw e; @@ -830,20 +814,20 @@ export const createDatabase = async ({ } }; - const createTables = async () => { + const createEnums = async () => { for ( let i = 0; - i < schemaBuild.statements.tables.sql.length; + i < schemaBuild.statements.enums.sql.length; i++ ) { await sql - .raw(schemaBuild.statements.tables.sql[i]!) + .raw(schemaBuild.statements.enums.sql[i]!) .execute(tx) .catch((_error) => { const error = _error as Error; if (!error.message.includes("already exists")) throw error; const e = new NonRetryableError( - `Unable to create table '${preBuild.namespace}'.'${schemaBuild.statements.tables.json[i]!.tableName}' because a table with that name already exists.`, + `Unable to create enum '${preBuild.namespace}'.'${schemaBuild.statements.enums.json[i]!.name}' because an enum with that name already exists.`, ); e.stack = undefined; throw e; @@ -851,7 +835,26 @@ export const createDatabase = async ({ } }; - const dropTables = async () => { + const previousApp = await tx + .selectFrom("_ponder_meta") + .where("key", "=", "app") + .select("value") + .executeTakeFirst() + .then((row) => row?.value); + + let createdTables = false; + + if (previousApp === undefined) { + await createEnums(); + await createTables(); + createdTables = true; + } else if ( + previousApp.is_dev === 1 || + (process.env.PONDER_EXPERIMENTAL_DB === "platform" && + previousApp.build_id !== buildId) || + (process.env.PONDER_EXPERIMENTAL_DB === "platform" && + previousApp.checkpoint === encodeCheckpoint(zeroCheckpoint)) + ) { for (const tableName of getTableNames(schemaBuild.schema)) { await tx.schema .dropTable(tableName.sql) @@ -864,203 +867,93 @@ export const createDatabase = async ({ .ifExists() .execute(); } - }; - - const dropEnums = async () => { for (const enumName of schemaBuild.statements.enums.json) { await tx.schema.dropType(enumName.name).ifExists().execute(); } - }; - - // If schema is empty, create tables - if (previousApp === undefined) { - await tx - .insertInto("_ponder_meta") - .values({ - key: "app", - value: newApp, - }) - .execute(); await createEnums(); await createTables(); - - common.logger.info({ - service: "database", - msg: `Created tables [${newApp.table_names.join(", ")}]`, - }); - - return { - status: "success", - checkpoint: encodeCheckpoint(zeroCheckpoint), - } as const; + createdTables = true; } - // dev fast path - if ( - previousApp.is_dev === 1 || - (process.env.PONDER_EXPERIMENTAL_DB === "platform" && - previousApp.build_id !== newApp.build_id) || - (process.env.PONDER_EXPERIMENTAL_DB === "platform" && - previousApp.checkpoint === encodeCheckpoint(zeroCheckpoint)) - ) { - await tx - .updateTable("_ponder_status") - .set({ - block_number: null, - block_timestamp: null, - ready: false, - }) - .execute(); - await tx - .updateTable("_ponder_meta") - .set({ value: newApp }) - .where("key", "=", "app") - .execute(); - - await dropTables(); - await dropEnums(); - - await createEnums(); - await createTables(); - + if (createdTables) { common.logger.info({ service: "database", - msg: `Created tables [${newApp.table_names.join(", ")}]`, + msg: `Created tables [${getTableNames(schemaBuild.schema) + .map(({ sql }) => sql) + .join(", ")}]`, }); - return { - status: "success", - checkpoint: encodeCheckpoint(zeroCheckpoint), - } as const; - } + // write metadata - // If crash recovery is not possible, error - if ( - common.options.command === "dev" || - previousApp.build_id !== newApp.build_id - ) { - const error = new NonRetryableError( - `Schema '${preBuild.namespace}' was previously used by a different Ponder app. Drop the schema first, or use a different schema. Read more: https://ponder.sh/docs/getting-started/database#database-schema`, - ); - error.stack = undefined; - throw error; - } + checkpoint = encodeCheckpoint(zeroCheckpoint); - const isAppUnlocked = - previousApp.is_locked === 0 || - previousApp.heartbeat_at + - common.options.databaseHeartbeatTimeout <= - Date.now(); - - // If app is locked, wait - if (isAppUnlocked === false) { - return { - status: "locked", - expiry: - previousApp.heartbeat_at + - common.options.databaseHeartbeatTimeout, - } as const; - } - - // Crash recovery is possible, recover + const newApp = { + is_locked: 1, + is_dev: common.options.command === "dev" ? 1 : 0, + heartbeat_at: Date.now(), + build_id: buildId, + checkpoint: encodeCheckpoint(zeroCheckpoint), + table_names: getTableNames(schemaBuild.schema).map( + ({ sql }) => sql, + ), + } satisfies PonderApp; - if (previousApp.checkpoint === encodeCheckpoint(zeroCheckpoint)) { await tx - .updateTable("_ponder_status") - .set({ - block_number: null, - block_timestamp: null, - ready: false, - }) - .execute(); - await tx - .updateTable("_ponder_meta") - .set({ value: newApp }) - .where("key", "=", "app") + .insertInto("_ponder_meta") + .values({ key: "app", value: newApp }) + .onConflict((oc) => + oc + .column("key") + // @ts-ignore + .doUpdateSet({ value: newApp }), + ) .execute(); + } else { + // schema one of: crash recovery, locked, error - await dropTables(); - await dropEnums(); + if ( + common.options.command === "dev" || + previousApp!.build_id !== buildId + ) { + const error = new NonRetryableError( + `Schema '${preBuild.namespace}' was previously used by a different Ponder app. Drop the schema first, or use a different schema. Read more: https://ponder.sh/docs/getting-started/database#database-schema`, + ); + error.stack = undefined; + throw error; + } - await createEnums(); - await createTables(); + // locked + + const isAppUnlocked = + previousApp!.is_locked === 0 || + previousApp!.heartbeat_at + + common.options.databaseHeartbeatTimeout <= + Date.now(); + + if (isAppUnlocked === false) { + return { + status: "locked", + expiry: + previousApp!.heartbeat_at + + common.options.databaseHeartbeatTimeout, + } as const; + } + + // crash recovery common.logger.info({ service: "database", - msg: `Created tables [${newApp.table_names.join(", ")}]`, + msg: `Detected crash recovery for build '${buildId}' in schema '${preBuild.namespace}' last active ${formatEta(Date.now() - previousApp!.heartbeat_at)} ago`, }); - - return { - status: "success", - checkpoint: encodeCheckpoint(zeroCheckpoint), - } as const; } - const checkpoint = previousApp.checkpoint; - newApp.checkpoint = checkpoint; - await tx .updateTable("_ponder_status") .set({ block_number: null, block_timestamp: null, ready: false }) .execute(); - await tx - .updateTable("_ponder_meta") - .set({ value: newApp }) - .where("key", "=", "app") - .execute(); - - common.logger.info({ - service: "database", - msg: `Detected crash recovery for build '${buildId}' in schema '${preBuild.namespace}' last active ${formatEta(Date.now() - previousApp.heartbeat_at)} ago`, - }); - - // Remove triggers - for (const tableName of getTableNames(schemaBuild.schema)) { - await sql - .raw( - `DROP TRIGGER IF EXISTS "${tableName.trigger}" ON "${preBuild.namespace}"."${tableName.sql}"`, - ) - .execute(tx); - } - - // Remove indexes - - for (const indexStatement of schemaBuild.statements.indexes.json) { - await tx.schema - .dropIndex(indexStatement.data.name) - .ifExists() - .execute(); - - common.logger.info({ - service: "database", - msg: `Dropped index '${indexStatement.data.name}' in schema '${preBuild.namespace}'`, - }); - } - - // Revert unfinalized data - - const { blockTimestamp, chainId, blockNumber } = - decodeCheckpoint(checkpoint); - - common.logger.info({ - service: "database", - msg: `Reverting operations after finalized checkpoint (timestamp=${blockTimestamp} chainId=${chainId} block=${blockNumber})`, - }); - - for (const tableName of getTableNames(schemaBuild.schema)) { - await revert({ - tableName, - checkpoint, - tx, - }); - } - - return { - status: "success", - checkpoint, - } as const; + return { status: "success" } as const; }), ); @@ -1115,8 +1008,116 @@ export const createDatabase = async ({ }); } }, common.options.databaseHeartbeatInterval); + }, + async getListenConnection() { + const trigger = "status_trigger"; + const notification = `${preBuild.namespace}_status_notify()`; + const channel = `${preBuild.namespace}_status_channel`; + + await this.wrap({ method: "getListenConnection" }, async () => { + await sql + .raw(` + CREATE OR REPLACE FUNCTION ${notification} + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + NOTIFY ${channel}; + RETURN NULL; + END; + $$;`) + .execute(qb.internal); + + await sql + .raw(` + CREATE OR REPLACE TRIGGER ${trigger} + AFTER INSERT OR UPDATE OR DELETE + ON "${preBuild.namespace}"._ponder_status + FOR EACH STATEMENT + EXECUTE PROCEDURE ${notification};`) + .execute(qb.internal); + }); + + if (dialect === "postgres") { + return { + dialect: "postgres", + connection: await (driver as PostgresDriver).internal.connect(), + }; + } + + return { + dialect: "pglite", + connection: (driver as PGliteDriver).instance, + }; + }, + async recoverCheckpoint() { + if (checkpoint !== undefined) { + return checkpoint; + } + + return this.wrap({ method: "recoverCheckpoint" }, () => + qb.internal.transaction().execute(async (tx) => { + const app = await tx + .selectFrom("_ponder_meta") + .where("key", "=", "app") + .select("value") + .executeTakeFirstOrThrow() + .then((row) => row.value); + + if (app.checkpoint === encodeCheckpoint(zeroCheckpoint)) { + for (const tableName of getTableNames(schemaBuild.schema)) { + await sql + .raw( + `TRUNCATE TABLE "${preBuild.namespace}"."${tableName.sql}", "${preBuild.namespace}"."${tableName.reorg}" CASCADE`, + ) + .execute(tx); + } + } else { + // Update metadata + + app.is_locked = 1; + app.is_dev = common.options.command === "dev" ? 1 : 0; - return { checkpoint: result.checkpoint }; + await tx + .updateTable("_ponder_meta") + .set({ value: app }) + + .where("key", "=", "app") + .execute(); + + // Remove triggers + + for (const tableName of getTableNames(schemaBuild.schema)) { + await sql + .raw( + `DROP TRIGGER IF EXISTS "${tableName.trigger}" ON "${preBuild.namespace}"."${tableName.sql}"`, + ) + .execute(tx); + } + + // Remove indexes + + for (const indexStatement of schemaBuild.statements.indexes.json) { + await tx.schema + .dropIndex(indexStatement.data.name) + .ifExists() + .execute(); + common.logger.info({ + service: "database", + msg: `Dropped index '${indexStatement.data.name}' in schema '${preBuild.namespace}'`, + }); + } + + // Revert unfinalized data + + for (const tableName of getTableNames(schemaBuild.schema)) { + await revert({ tableName, checkpoint: app.checkpoint, tx }); + } + } + + return app.checkpoint; + }), + ); }, async createIndexes() { for (const statement of schemaBuild.statements.indexes.sql) { diff --git a/packages/create-ponder/package.json b/packages/create-ponder/package.json index 9bf5ff7ff..f3947efce 100644 --- a/packages/create-ponder/package.json +++ b/packages/create-ponder/package.json @@ -1,6 +1,6 @@ { "name": "create-ponder", - "version": "0.9.0-next.1", + "version": "0.9.0-next.3", "type": "module", "description": "A CLI tool to create Ponder apps", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58f7186c3..a6b98b8e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -528,6 +528,9 @@ importers: '@types/pg': specifier: ^8.10.9 version: 8.10.9 + tsx: + specifier: ^4.19.2 + version: 4.19.2 examples/with-client/ponder: dependencies: @@ -564,7 +567,7 @@ importers: dependencies: forge-std: specifier: github:foundry-rs/forge-std - version: https://codeload.github.com/foundry-rs/forge-std/tar.gz/d3db4ef90a72b7d24aa5a2e5c649593eaef7801d + version: https://codeload.github.com/foundry-rs/forge-std/tar.gz/726a6ee5fc8427a0013d6f624e486c9130c0e336 examples/with-foundry/ponder: dependencies: @@ -717,6 +720,9 @@ importers: drizzle-orm: specifier: 0.36.4 version: 0.36.4(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(@types/pg@8.10.9)(@types/react@18.2.46)(kysely@0.26.3)(pg@8.11.3)(react@18.2.0) + undici: + specifier: ^7.2.0 + version: 7.2.0 devDependencies: tsup: specifier: ^8.0.1 @@ -1762,6 +1768,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.23.1': + resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.16.17': resolution: {integrity: sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg==} engines: {node: '>=12'} @@ -1780,6 +1792,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.23.1': + resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.16.17': resolution: {integrity: sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw==} engines: {node: '>=12'} @@ -1798,6 +1816,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.23.1': + resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.16.17': resolution: {integrity: sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ==} engines: {node: '>=12'} @@ -1816,6 +1840,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.23.1': + resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.16.17': resolution: {integrity: sha512-/2agbUEfmxWHi9ARTX6OQ/KgXnOWfsNlTeLcoV7HSuSTv63E4DqtAc+2XqGw1KHxKMHGZgbVCZge7HXWX9Vn+w==} engines: {node: '>=12'} @@ -1834,6 +1864,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.23.1': + resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.16.17': resolution: {integrity: sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg==} engines: {node: '>=12'} @@ -1852,6 +1888,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.23.1': + resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.16.17': resolution: {integrity: sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw==} engines: {node: '>=12'} @@ -1870,6 +1912,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.23.1': + resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.16.17': resolution: {integrity: sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug==} engines: {node: '>=12'} @@ -1888,6 +1936,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.23.1': + resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.16.17': resolution: {integrity: sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g==} engines: {node: '>=12'} @@ -1906,6 +1960,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.23.1': + resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.16.17': resolution: {integrity: sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ==} engines: {node: '>=12'} @@ -1924,6 +1984,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.23.1': + resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.16.17': resolution: {integrity: sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg==} engines: {node: '>=12'} @@ -1942,6 +2008,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.23.1': + resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.16.17': resolution: {integrity: sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ==} engines: {node: '>=12'} @@ -1960,6 +2032,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.23.1': + resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.16.17': resolution: {integrity: sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw==} engines: {node: '>=12'} @@ -1978,6 +2056,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.23.1': + resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.16.17': resolution: {integrity: sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g==} engines: {node: '>=12'} @@ -1996,6 +2080,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.23.1': + resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.16.17': resolution: {integrity: sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw==} engines: {node: '>=12'} @@ -2014,6 +2104,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.23.1': + resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.16.17': resolution: {integrity: sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w==} engines: {node: '>=12'} @@ -2032,6 +2128,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.23.1': + resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.16.17': resolution: {integrity: sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==} engines: {node: '>=12'} @@ -2050,6 +2152,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.23.1': + resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-x64@0.16.17': resolution: {integrity: sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA==} engines: {node: '>=12'} @@ -2068,6 +2176,18 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.23.1': + resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.23.1': + resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.16.17': resolution: {integrity: sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg==} engines: {node: '>=12'} @@ -2086,6 +2206,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.23.1': + resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/sunos-x64@0.16.17': resolution: {integrity: sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw==} engines: {node: '>=12'} @@ -2104,6 +2230,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.23.1': + resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.16.17': resolution: {integrity: sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw==} engines: {node: '>=12'} @@ -2122,6 +2254,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.23.1': + resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.16.17': resolution: {integrity: sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig==} engines: {node: '>=12'} @@ -2140,6 +2278,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.23.1': + resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.16.17': resolution: {integrity: sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q==} engines: {node: '>=12'} @@ -2158,6 +2302,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.23.1': + resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@escape.tech/graphql-armor-max-aliases@2.4.0': resolution: {integrity: sha512-d4V9EgtPRG9HIoPHuanFNLHj1ENB1YkZi9FbiBiH88x5VahCjVpMXDgKQGkG6RUTOODU4XKp0/ZgaOq0pX5oEA==} engines: {node: '>=18.0.0'} @@ -4526,6 +4676,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.23.1: + resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} @@ -4873,9 +5028,9 @@ packages: forever-agent@0.6.1: resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} - forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/d3db4ef90a72b7d24aa5a2e5c649593eaef7801d: - resolution: {tarball: https://codeload.github.com/foundry-rs/forge-std/tar.gz/d3db4ef90a72b7d24aa5a2e5c649593eaef7801d} - version: 1.9.4 + forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/726a6ee5fc8427a0013d6f624e486c9130c0e336: + resolution: {tarball: https://codeload.github.com/foundry-rs/forge-std/tar.gz/726a6ee5fc8427a0013d6f624e486c9130c0e336} + version: 1.9.5 form-data@2.3.3: resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} @@ -7985,6 +8140,11 @@ packages: typescript: optional: true + tsx@4.19.2: + resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==} + engines: {node: '>=18.0.0'} + hasBin: true + tty-table@4.2.3: resolution: {integrity: sha512-Fs15mu0vGzCrj8fmJNP7Ynxt5J7praPXqFN0leZeZBXJwkMxv9cb2D454k1ltrtUSJbZ4yH4e0CynsHLxmUfFA==} engines: {node: '>=8.0.0'} @@ -8088,6 +8248,10 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici@7.2.0: + resolution: {integrity: sha512-klt+0S55GBViA9nsq48/NSCo4YX5mjydjypxD7UmHh/brMu8h/Mhd/F7qAeoH2NOO8SDTk6kjnTFc4WpzmfYpQ==} + engines: {node: '>=20.18.1'} + unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} engines: {node: '>=4'} @@ -9589,6 +9753,9 @@ snapshots: '@esbuild/aix-ppc64@0.19.11': optional: true + '@esbuild/aix-ppc64@0.23.1': + optional: true + '@esbuild/android-arm64@0.16.17': optional: true @@ -9598,6 +9765,9 @@ snapshots: '@esbuild/android-arm64@0.19.11': optional: true + '@esbuild/android-arm64@0.23.1': + optional: true + '@esbuild/android-arm@0.16.17': optional: true @@ -9607,6 +9777,9 @@ snapshots: '@esbuild/android-arm@0.19.11': optional: true + '@esbuild/android-arm@0.23.1': + optional: true + '@esbuild/android-x64@0.16.17': optional: true @@ -9616,6 +9789,9 @@ snapshots: '@esbuild/android-x64@0.19.11': optional: true + '@esbuild/android-x64@0.23.1': + optional: true + '@esbuild/darwin-arm64@0.16.17': optional: true @@ -9625,6 +9801,9 @@ snapshots: '@esbuild/darwin-arm64@0.19.11': optional: true + '@esbuild/darwin-arm64@0.23.1': + optional: true + '@esbuild/darwin-x64@0.16.17': optional: true @@ -9634,6 +9813,9 @@ snapshots: '@esbuild/darwin-x64@0.19.11': optional: true + '@esbuild/darwin-x64@0.23.1': + optional: true + '@esbuild/freebsd-arm64@0.16.17': optional: true @@ -9643,6 +9825,9 @@ snapshots: '@esbuild/freebsd-arm64@0.19.11': optional: true + '@esbuild/freebsd-arm64@0.23.1': + optional: true + '@esbuild/freebsd-x64@0.16.17': optional: true @@ -9652,6 +9837,9 @@ snapshots: '@esbuild/freebsd-x64@0.19.11': optional: true + '@esbuild/freebsd-x64@0.23.1': + optional: true + '@esbuild/linux-arm64@0.16.17': optional: true @@ -9661,6 +9849,9 @@ snapshots: '@esbuild/linux-arm64@0.19.11': optional: true + '@esbuild/linux-arm64@0.23.1': + optional: true + '@esbuild/linux-arm@0.16.17': optional: true @@ -9670,6 +9861,9 @@ snapshots: '@esbuild/linux-arm@0.19.11': optional: true + '@esbuild/linux-arm@0.23.1': + optional: true + '@esbuild/linux-ia32@0.16.17': optional: true @@ -9679,6 +9873,9 @@ snapshots: '@esbuild/linux-ia32@0.19.11': optional: true + '@esbuild/linux-ia32@0.23.1': + optional: true + '@esbuild/linux-loong64@0.16.17': optional: true @@ -9688,6 +9885,9 @@ snapshots: '@esbuild/linux-loong64@0.19.11': optional: true + '@esbuild/linux-loong64@0.23.1': + optional: true + '@esbuild/linux-mips64el@0.16.17': optional: true @@ -9697,6 +9897,9 @@ snapshots: '@esbuild/linux-mips64el@0.19.11': optional: true + '@esbuild/linux-mips64el@0.23.1': + optional: true + '@esbuild/linux-ppc64@0.16.17': optional: true @@ -9706,6 +9909,9 @@ snapshots: '@esbuild/linux-ppc64@0.19.11': optional: true + '@esbuild/linux-ppc64@0.23.1': + optional: true + '@esbuild/linux-riscv64@0.16.17': optional: true @@ -9715,6 +9921,9 @@ snapshots: '@esbuild/linux-riscv64@0.19.11': optional: true + '@esbuild/linux-riscv64@0.23.1': + optional: true + '@esbuild/linux-s390x@0.16.17': optional: true @@ -9724,6 +9933,9 @@ snapshots: '@esbuild/linux-s390x@0.19.11': optional: true + '@esbuild/linux-s390x@0.23.1': + optional: true + '@esbuild/linux-x64@0.16.17': optional: true @@ -9733,6 +9945,9 @@ snapshots: '@esbuild/linux-x64@0.19.11': optional: true + '@esbuild/linux-x64@0.23.1': + optional: true + '@esbuild/netbsd-x64@0.16.17': optional: true @@ -9742,6 +9957,12 @@ snapshots: '@esbuild/netbsd-x64@0.19.11': optional: true + '@esbuild/netbsd-x64@0.23.1': + optional: true + + '@esbuild/openbsd-arm64@0.23.1': + optional: true + '@esbuild/openbsd-x64@0.16.17': optional: true @@ -9751,6 +9972,9 @@ snapshots: '@esbuild/openbsd-x64@0.19.11': optional: true + '@esbuild/openbsd-x64@0.23.1': + optional: true + '@esbuild/sunos-x64@0.16.17': optional: true @@ -9760,6 +9984,9 @@ snapshots: '@esbuild/sunos-x64@0.19.11': optional: true + '@esbuild/sunos-x64@0.23.1': + optional: true + '@esbuild/win32-arm64@0.16.17': optional: true @@ -9769,6 +9996,9 @@ snapshots: '@esbuild/win32-arm64@0.19.11': optional: true + '@esbuild/win32-arm64@0.23.1': + optional: true + '@esbuild/win32-ia32@0.16.17': optional: true @@ -9778,6 +10008,9 @@ snapshots: '@esbuild/win32-ia32@0.19.11': optional: true + '@esbuild/win32-ia32@0.23.1': + optional: true + '@esbuild/win32-x64@0.16.17': optional: true @@ -9787,6 +10020,9 @@ snapshots: '@esbuild/win32-x64@0.19.11': optional: true + '@esbuild/win32-x64@0.23.1': + optional: true + '@escape.tech/graphql-armor-max-aliases@2.4.0': dependencies: graphql: 16.8.2 @@ -12523,6 +12759,33 @@ snapshots: '@esbuild/win32-ia32': 0.19.11 '@esbuild/win32-x64': 0.19.11 + esbuild@0.23.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.23.1 + '@esbuild/android-arm': 0.23.1 + '@esbuild/android-arm64': 0.23.1 + '@esbuild/android-x64': 0.23.1 + '@esbuild/darwin-arm64': 0.23.1 + '@esbuild/darwin-x64': 0.23.1 + '@esbuild/freebsd-arm64': 0.23.1 + '@esbuild/freebsd-x64': 0.23.1 + '@esbuild/linux-arm': 0.23.1 + '@esbuild/linux-arm64': 0.23.1 + '@esbuild/linux-ia32': 0.23.1 + '@esbuild/linux-loong64': 0.23.1 + '@esbuild/linux-mips64el': 0.23.1 + '@esbuild/linux-ppc64': 0.23.1 + '@esbuild/linux-riscv64': 0.23.1 + '@esbuild/linux-s390x': 0.23.1 + '@esbuild/linux-x64': 0.23.1 + '@esbuild/netbsd-x64': 0.23.1 + '@esbuild/openbsd-arm64': 0.23.1 + '@esbuild/openbsd-x64': 0.23.1 + '@esbuild/sunos-x64': 0.23.1 + '@esbuild/win32-arm64': 0.23.1 + '@esbuild/win32-ia32': 0.23.1 + '@esbuild/win32-x64': 0.23.1 + escalade@3.1.1: {} escape-string-regexp@1.0.5: {} @@ -13012,7 +13275,7 @@ snapshots: forever-agent@0.6.1: {} - forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/d3db4ef90a72b7d24aa5a2e5c649593eaef7801d: {} + forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/726a6ee5fc8427a0013d6f624e486c9130c0e336: {} form-data@2.3.3: dependencies: @@ -16934,6 +17197,13 @@ snapshots: - supports-color - ts-node + tsx@4.19.2: + dependencies: + esbuild: 0.23.1 + get-tsconfig: 4.8.1 + optionalDependencies: + fsevents: 2.3.3 + tty-table@4.2.3: dependencies: chalk: 4.1.2 @@ -17026,6 +17296,8 @@ snapshots: undici-types@5.26.5: {} + undici@7.2.0: {} + unicode-canonical-property-names-ecmascript@2.0.0: {} unicode-match-property-ecmascript@2.0.0: