├── test ├── browser │ ├── server │ │ ├── env.d.ts │ │ ├── public │ │ │ └── favicon.ico │ │ ├── src │ │ │ ├── main.ts │ │ │ └── App.vue │ │ ├── README.md │ │ ├── tsconfig.config.json │ │ ├── tsconfig.json │ │ ├── index.html │ │ ├── .gitignore │ │ ├── package.json │ │ └── vite.config.ts │ └── test │ │ ├── setup.js │ │ └── happy.spec.js ├── setup.ts ├── parser.test.ts ├── await.test.ts ├── binding.test.ts ├── config.test.ts ├── chains.test.ts ├── thirdparty.test.ts ├── aliases.test.ts └── subscribe.test.ts ├── .prettierignore ├── .prettierrc.json ├── .github ├── dependabot.yml ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── review.yml │ ├── docs.yml │ ├── test.yml │ ├── publish.yml │ └── combine-prs.yml └── PULL_REQUEST_TEMPLATE.md ├── tsconfig.cjs.json ├── .gitignore ├── src ├── index.ts ├── validator │ ├── errors.ts │ ├── health.ts │ ├── client │ │ ├── index.ts │ │ ├── LICENCE │ │ ├── types.ts │ │ ├── README.md │ │ ├── fetcher.ts │ │ └── validator.ts │ ├── version.ts │ ├── query.ts │ ├── tables.ts │ ├── receipt.ts │ └── index.ts ├── registry │ ├── tables.ts │ ├── transfer.ts │ ├── controller.ts │ ├── contract.ts │ ├── run.ts │ ├── create.ts │ ├── index.ts │ └── utils.ts ├── helpers │ ├── index.ts │ ├── LICENCE │ ├── utils.ts │ ├── parser.ts │ ├── await.ts │ ├── config.ts │ ├── ethers.ts │ ├── chains.ts │ ├── binding.ts │ └── subscribe.ts └── lowlevel.ts ├── fixup ├── .mocharc.json ├── lint.tsconfig.json ├── tsconfig.json ├── .eslintrc.json ├── LICENSE ├── playwright.config.js ├── package.json └── LICENSE-APACHE /test/browser/server/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /coverage/ 3 | /docs/ 4 | /src/validator/client/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /test/browser/server/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tablelandnetwork/js-tableland/HEAD/test/browser/server/public/favicon.ico -------------------------------------------------------------------------------- /test/browser/server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | 4 | createApp(App).mount("#app"); 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "./dist/cjs/" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | /coverage/ 4 | .vscode 5 | .DS_Store 6 | /docs/ 7 | /test-results/ 8 | /playwright-report/ 9 | /playwright/.cache/ 10 | -------------------------------------------------------------------------------- /test/browser/server/README.md: -------------------------------------------------------------------------------- 1 | This is a very simple vue 3 vite app to test the sdk against. 2 | To start it manually do `npm run dev`. 3 | To use it for tests via Playwright from the root of the repo do `npm run test:browser`. 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as helpers from "./helpers/index.js"; 2 | export * from "./validator/index.js"; 3 | export * from "./registry/index.js"; 4 | export * from "./database.js"; 5 | export * from "./statement.js"; 6 | export { helpers }; 7 | -------------------------------------------------------------------------------- /src/validator/errors.ts: -------------------------------------------------------------------------------- 1 | import { ApiError } from "./client/index.js"; 2 | 3 | export function hoistApiError(err: unknown): never { 4 | if (err instanceof ApiError) { 5 | err.message = err.data?.message ?? err.statusText; 6 | } 7 | throw err; 8 | } 9 | -------------------------------------------------------------------------------- /fixup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Add package.json files to cjs/mjs subtrees 4 | # 5 | 6 | cat >dist/cjs/package.json <dist/esm/package.json < { 8 | const health = getFetcher(config).path("/health").method("get").create(); 9 | const { ok } = await health({}, opts); 10 | return ok; 11 | } 12 | -------------------------------------------------------------------------------- /test/browser/server/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tableland SDK Test App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/browser/server/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /lint.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "test", "playwright.config.js"], 3 | "exclude": ["dist", "src/validator/client/*"], 4 | "compilerOptions": { 5 | "target": "ES2020", 6 | "module": "ES2022", 7 | "lib": ["DOM", "ES2022"], 8 | "importHelpers": false, 9 | "moduleResolution": "Node", 10 | "esModuleInterop": true, 11 | "strict": true, 12 | "allowJs": true, 13 | "skipLibCheck": true, 14 | "declaration": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ES2022", 5 | "lib": ["DOM", "ES2022"], 6 | "outDir": "./dist/esm/", 7 | "importHelpers": false, 8 | "moduleResolution": "Node", 9 | "esModuleInterop": true, 10 | "strict": true, 11 | "allowJs": true, 12 | "skipLibCheck": true, 13 | "declaration": true, 14 | "declarationMap": true, 15 | "sourceMap": true 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /test/browser/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite" 7 | }, 8 | "dependencies": { 9 | "vue": "^3.2.45" 10 | }, 11 | "devDependencies": { 12 | "@tableland/local": "^2.0.1", 13 | "@types/node": "^18.11.12", 14 | "@vitejs/plugin-vue": "^4.0.0", 15 | "@vue/tsconfig": "^0.1.3", 16 | "npm-run-all": "^4.1.5", 17 | "typescript": "~4.7.4", 18 | "vite": "^4.1.5", 19 | "vue-tsc": "^1.0.12" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/validator/client/index.ts: -------------------------------------------------------------------------------- 1 | import { Fetcher } from "./fetcher.js"; 2 | import { ApiResponse, ApiError, FetchConfig } from "./types.js"; 3 | import type { paths as Paths, components as Components } from "./validator.js"; 4 | 5 | export { ApiResponse, Fetcher, ApiError, FetchConfig }; 6 | export type { Paths, Components }; 7 | 8 | export function getFetcher( 9 | config: FetchConfig 10 | ): ReturnType> { 11 | const fetcher = Fetcher.for(); 12 | fetcher.configure(config); 13 | return fetcher; 14 | } 15 | -------------------------------------------------------------------------------- /test/browser/server/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from "node:url"; 2 | 3 | import { defineConfig } from "vite"; 4 | import vue from "@vitejs/plugin-vue"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [vue()], 9 | resolve: { 10 | alias: { 11 | "@": fileURLToPath(new URL("./src", import.meta.url)), 12 | }, 13 | }, 14 | optimizeDeps: { 15 | exclude: ["@tableland/sqlparser"], 16 | }, 17 | server: { 18 | fs: { 19 | allow: ["../../.."], 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2022": true, 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "plugins": ["@typescript-eslint", "import"], 9 | "extends": ["standard-with-typescript", "prettier"], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": 12, 13 | "project": "./lint.tsconfig.json" 14 | }, 15 | "rules": { 16 | "import/order": "warn", 17 | "@typescript-eslint/no-confusing-void-expression": "off" 18 | }, 19 | "ignorePatterns": [ 20 | "/dist/", 21 | "/coverage/", 22 | "/docs/", 23 | "/src/validator/client/", 24 | "*.d.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { after, before } from "mocha"; 2 | import { LocalTableland } from "@tableland/local"; 3 | 4 | const getTimeoutFactor = function (): number { 5 | const envFactor = Number(process.env.TEST_TIMEOUT_FACTOR); 6 | if (!isNaN(envFactor) && envFactor > 0) { 7 | return envFactor; 8 | } 9 | return 1; 10 | }; 11 | 12 | export const TEST_TIMEOUT_FACTOR = getTimeoutFactor(); 13 | 14 | const lt = new LocalTableland({ 15 | silent: false, 16 | verbose: true, 17 | }); 18 | 19 | before(async function () { 20 | this.timeout(TEST_TIMEOUT_FACTOR * 30000); 21 | await lt.start(); 22 | }); 23 | 24 | after(async function () { 25 | await lt.shutdown(); 26 | }); 27 | -------------------------------------------------------------------------------- /src/registry/tables.ts: -------------------------------------------------------------------------------- 1 | import { type SignerConfig } from "../helpers/config.js"; 2 | import { type TableIdentifier, getContractAndOverrides } from "./contract.js"; 3 | 4 | export async function listTables( 5 | { signer }: SignerConfig, 6 | owner?: string 7 | ): Promise { 8 | const address = owner ?? (await signer.getAddress()); 9 | const chainId = await signer.getChainId(); 10 | signer._checkProvider(); 11 | const { contract, overrides } = await getContractAndOverrides( 12 | signer, 13 | chainId 14 | ); 15 | const tokens = await contract.tokensOfOwner(address, overrides); 16 | return tokens.map((token) => ({ tableId: token.toString(), chainId })); 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. E.g. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /src/registry/transfer.ts: -------------------------------------------------------------------------------- 1 | import { type SignerConfig } from "../helpers/config.js"; 2 | import { type ContractTransaction } from "../helpers/ethers.js"; 3 | import { type TableIdentifier, getContractSetup } from "./contract.js"; 4 | 5 | export interface TransferParams { 6 | /** 7 | * Name or tableId and chainId of the token to be transferred. 8 | */ 9 | tableName: string | TableIdentifier; 10 | /** 11 | * Address to receive the ownership of the given token ID. 12 | */ 13 | to: string; 14 | } 15 | 16 | export async function safeTransferFrom( 17 | { signer }: SignerConfig, 18 | params: TransferParams 19 | ): Promise { 20 | const { contract, overrides, tableId } = await getContractSetup( 21 | signer, 22 | params.tableName 23 | ); 24 | const caller = await signer.getAddress(); 25 | return await contract["safeTransferFrom(address,address,uint256)"]( 26 | caller, 27 | params.to, 28 | tableId, 29 | overrides 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /test/browser/test/setup.js: -------------------------------------------------------------------------------- 1 | // NOTES: 2 | // - Playwright will start the app in `../server` before running tests 3 | // - Playwright uses a custom loader. https://nodejs.org/docs/latest-v18.x/api/esm.html#loaders 4 | // This does not work with Hardhat and Local Tableland. This may be related to https://github.com/microsoft/playwright/issues/16185 5 | // As a workaround the playwright tests are run with the PW_TS_ESM_ON env var set. 6 | // Which means tests can not use typescript. 7 | import { LocalTableland } from "../server/node_modules/@tableland/local/dist/esm/main.js"; 8 | 9 | // TODO: potentially convert to typescript, but until then we need to ignore ts specific linting rules 10 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 11 | export default async function () { 12 | const lt = new LocalTableland({ 13 | silent: true 14 | }); 15 | 16 | await lt.start(); 17 | 18 | return async function () { 19 | await lt.shutdown(); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | type Signal, 3 | type Wait, 4 | type SignalAndInterval, 5 | type Interval, 6 | } from "./await.js"; 7 | export { 8 | type ChainName, 9 | type ChainInfo, 10 | supportedChains, 11 | getBaseUrl, 12 | getChainId, 13 | getChainInfo, 14 | getContractAddress, 15 | isTestnet, 16 | overrideDefaults, 17 | } from "./chains.js"; 18 | export { 19 | type ReadConfig, 20 | type SignerConfig, 21 | type Config, 22 | type AutoWaitConfig, 23 | type AliasesNameMap, 24 | type NameMapping, 25 | checkWait, 26 | extractBaseUrl, 27 | extractChainId, 28 | extractSigner, 29 | jsonFileAliases, 30 | } from "./config.js"; 31 | export { 32 | type Signer, 33 | type ExternalProvider, 34 | getDefaultProvider, 35 | type ContractTransaction, 36 | type ContractReceipt, 37 | type RegistryReceipt, 38 | type MultiEventTransactionReceipt, 39 | getSigner, 40 | getContractReceipt, 41 | } from "./ethers.js"; 42 | export { 43 | normalize, 44 | validateTableName, 45 | type NormalizedStatement, 46 | type StatementType, 47 | } from "./parser.js"; 48 | export { TableEventBus } from "./subscribe.js"; 49 | -------------------------------------------------------------------------------- /test/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { strictEqual, rejects } from "assert"; 2 | import { describe, test } from "mocha"; 3 | import { normalize, validateTableName } from "../src/helpers/parser.js"; 4 | 5 | describe("parser", function () { 6 | describe("normalize()", function () { 7 | test("when called incorrectly", async function () { 8 | await rejects( 9 | // @ts-expect-error need to tell ts to ignore this since we are testing a failure when used without ts 10 | normalize(123), 11 | (err: any) => { 12 | strictEqual(err.message, "SQL statement must be a String"); 13 | return true; 14 | } 15 | ); 16 | }); 17 | }); 18 | 19 | describe("validateTableName()", function () { 20 | test("when called incorrectly", async function () { 21 | await rejects( 22 | // @ts-expect-error need to tell ts to ignore this since we are testing a failure when used without ts 23 | validateTableName(123), 24 | (err: any) => { 25 | strictEqual(err.message, "table name must be a String"); 26 | return true; 27 | } 28 | ); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2022 Tableland Network Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/validator/version.ts: -------------------------------------------------------------------------------- 1 | import { camelize, type Camelize } from "../helpers/utils.js"; 2 | import { type Signal } from "../helpers/await.js"; 3 | import { 4 | getFetcher, 5 | type Components, 6 | type FetchConfig, 7 | } from "./client/index.js"; 8 | import { hoistApiError } from "./errors.js"; 9 | 10 | type Response = Components["schemas"]["VersionInfo"]; 11 | type AssertedResponse = Required; 12 | export type Version = Camelize; 13 | 14 | function assertResponse(obj: Response): obj is AssertedResponse { 15 | return Object.values(obj).every((v) => v != null); 16 | } 17 | 18 | function transformResponse(obj: Response): Version { 19 | if (assertResponse(obj)) { 20 | return camelize(obj); 21 | } 22 | /* c8 ignore next 2 */ 23 | throw new Error("malformed version repsonse"); 24 | } 25 | 26 | export async function getVersion( 27 | config: FetchConfig, 28 | opts: Signal = {} 29 | ): Promise { 30 | const version = getFetcher(config).path("/version").method("get").create(); 31 | const { data } = await version({}, opts).catch(hoistApiError); 32 | const transformed = transformResponse(data); 33 | return transformed; 34 | } 35 | -------------------------------------------------------------------------------- /src/validator/client/LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tableland Network Contributors 4 | Copyright (c) 2021 Ajai Shankar 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /src/helpers/LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tableland Network Contributors 4 | Copyright (c) 2021 Kristoffer Brabrand 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/review.yml: -------------------------------------------------------------------------------- 1 | name: Review 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | concurrency: 9 | group: review-${{github.ref}} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | review: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: true 17 | matrix: 18 | nodejs: [16, 18] 19 | 20 | steps: 21 | - name: Checkout 🛎️ 22 | uses: actions/checkout@v2.3.1 23 | with: 24 | persist-credentials: false 25 | 26 | - name: Cache 📦 27 | uses: actions/cache@v1 28 | with: 29 | path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS 30 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 31 | restore-keys: | 32 | ${{ runner.os }}-build-${{ env.cache-name }}- 33 | ${{ runner.os }}-build- 34 | ${{ runner.os }}- 35 | 36 | - name: Setup ⬢ 37 | uses: actions/setup-node@v2-beta 38 | with: 39 | node-version: ${{ matrix.nodejs }} 40 | 41 | - name: Install 🔧 42 | run: npm install 43 | 44 | - name: Lint/Format 🙈 45 | run: npm run prettier && npm run lint 46 | 47 | - name: Docs 📓 48 | run: npm run docs 49 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | concurrency: 8 | group: docs-${{github.ref}} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | docs: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 🛎️ 17 | uses: actions/checkout@v2.3.1 18 | with: 19 | persist-credentials: false 20 | 21 | - name: Cache 📦 22 | uses: actions/cache@v1 23 | with: 24 | path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS 25 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | ${{ runner.os }}-build-${{ env.cache-name }}- 28 | ${{ runner.os }}-build- 29 | ${{ runner.os }}- 30 | 31 | - name: Setup ⬢ 32 | uses: actions/setup-node@v2-beta 33 | with: 34 | node-version: 18 35 | 36 | - name: Install 🔧 37 | run: npm install 38 | 39 | - name: Build 🛠 40 | run: npm run build 41 | 42 | - name: Docs 📓 43 | run: | 44 | npm run docs 45 | touch docs/.nojekyll 46 | 47 | - name: Deploy 🚀 48 | uses: JamesIves/github-pages-deploy-action@3.7.1 49 | with: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | BRANCH: gh-pages 52 | FOLDER: docs 53 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | # enable test runners to know where they are 9 | env: 10 | CI: true 11 | 12 | concurrency: 13 | group: test-${{github.ref}} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | test: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | fail-fast: true 21 | matrix: 22 | nodejs: [16, 18] 23 | 24 | steps: 25 | - name: Checkout 🛎️ 26 | uses: actions/checkout@v2.3.1 27 | with: 28 | persist-credentials: false 29 | 30 | - name: Cache 📦 31 | uses: actions/cache@v1 32 | with: 33 | path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS 34 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 35 | restore-keys: | 36 | ${{ runner.os }}-build-${{ env.cache-name }}- 37 | ${{ runner.os }}-build- 38 | ${{ runner.os }}- 39 | 40 | - name: Setup ⬢ 41 | uses: actions/setup-node@v2-beta 42 | with: 43 | node-version: ${{ matrix.nodejs }} 44 | 45 | - name: Install 🔧 46 | run: npm install 47 | 48 | - name: Build 🛠 49 | run: npm run build 50 | 51 | - name: Test/Coverage 🧪 52 | run: TEST_TIMEOUT_FACTOR=3 npm run coverage 53 | 54 | - name: Install Playwright Browsers 55 | run: npx playwright install --with-deps 56 | 57 | - name: Test/Browser 🧪 58 | run: | 59 | cd test/browser/server 60 | npm install 61 | cd ../../ 62 | npm run test:browser 63 | -------------------------------------------------------------------------------- /src/registry/controller.ts: -------------------------------------------------------------------------------- 1 | import { type SignerConfig } from "../helpers/config.js"; 2 | import { type ContractTransaction } from "../helpers/ethers.js"; 3 | import { type TableIdentifier, getContractSetup } from "./contract.js"; 4 | 5 | export interface SetParams { 6 | /** 7 | * Name or tableId and chainId of the token to be transferred. 8 | */ 9 | tableName: string | TableIdentifier; 10 | /** 11 | * Address of the contract to use as a controller. 12 | */ 13 | controller: string; 14 | } 15 | 16 | export async function setController( 17 | { signer }: SignerConfig, 18 | params: SetParams 19 | ): Promise { 20 | const { contract, overrides, tableId } = await getContractSetup( 21 | signer, 22 | params.tableName 23 | ); 24 | const caller = await signer.getAddress(); 25 | const controller = params.controller; 26 | return await contract.setController(caller, tableId, controller, overrides); 27 | } 28 | 29 | export async function lockController( 30 | { signer }: SignerConfig, 31 | tableName: string | TableIdentifier 32 | ): Promise { 33 | const { contract, overrides, tableId } = await getContractSetup( 34 | signer, 35 | tableName 36 | ); 37 | const caller = await signer.getAddress(); 38 | return await contract.lockController(caller, tableId, overrides); 39 | } 40 | 41 | export async function getController( 42 | { signer }: SignerConfig, 43 | tableName: string | TableIdentifier 44 | ): Promise { 45 | const { contract, overrides, tableId } = await getContractSetup( 46 | signer, 47 | tableName 48 | ); 49 | return await contract.getController(tableId, overrides); 50 | } 51 | -------------------------------------------------------------------------------- /src/validator/query.ts: -------------------------------------------------------------------------------- 1 | import { type Signal } from "../helpers/await.js"; 2 | import { type FetchConfig, type Paths, getFetcher } from "./client/index.js"; 3 | import { hoistApiError } from "./errors.js"; 4 | 5 | /** 6 | * ValueOf represents only the values of a given keyed interface. 7 | */ 8 | export type ValueOf = T[keyof T]; 9 | 10 | /** 11 | * TableFormat represents a object with rows and columns. 12 | */ 13 | export interface TableFormat { 14 | rows: Array>; 15 | columns: Array<{ name: string }>; 16 | } 17 | 18 | /** 19 | * ObjectsFormat represents an array of rows as key/value objects. 20 | */ 21 | export type ObjectsFormat = T[]; 22 | 23 | export type BaseParams = Paths["/query"]["get"]["parameters"]["query"]; 24 | export type Format = BaseParams["format"]; 25 | export type Params = BaseParams & { format?: T }; 26 | 27 | export async function getQuery( 28 | config: FetchConfig, 29 | params: Params<"objects" | undefined>, 30 | opts?: Signal 31 | ): Promise>; 32 | export async function getQuery( 33 | config: FetchConfig, 34 | params: Params<"table">, 35 | opts?: Signal 36 | ): Promise>; 37 | export async function getQuery( 38 | config: FetchConfig, 39 | params: Params, 40 | opts: Signal = {} 41 | ): Promise | TableFormat> { 42 | const queryByStatement = getFetcher(config) 43 | .path("/query") 44 | .method("get") 45 | .create(); 46 | const { data } = await queryByStatement(params, opts).catch(hoistApiError); 47 | switch (params.format) { 48 | case "table": 49 | return data as any; 50 | default: 51 | return data as any; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | export type PartialRequired = Omit, S> & 2 | Partial>; 3 | 4 | export function getRange(size: number, startAt: number = 0): readonly number[] { 5 | return [...Array(size).keys()].map((i) => i + startAt); 6 | } 7 | 8 | export const getDelay = async (ms: number): Promise => 9 | await new Promise((resolve) => setTimeout(resolve, ms)); 10 | 11 | // Vendored from https://github.com/kbrabrand/camelize-ts/blob/main/src/index.ts 12 | // Copyright (c) 2021 Kristoffer Brabrand 13 | 14 | type CamelCase = 15 | S extends `${infer P1}_${infer P2}${infer P3}` 16 | ? `${P1}${Uppercase}${CamelCase}` 17 | : S; 18 | 19 | export type Camelize = { 20 | [K in keyof T as CamelCase]: T[K] extends Array 21 | ? U extends Record | undefined 22 | ? Array> 23 | : T[K] 24 | : T[K] extends Record | undefined 25 | ? Camelize 26 | : T[K]; 27 | }; 28 | 29 | export function camelCase(str: string): string { 30 | return str.replace(/[_.-](\w|$)/g, function (_, x) { 31 | return x.toUpperCase(); 32 | }); 33 | } 34 | 35 | function walk(obj: any): any { 36 | if (obj == null || typeof obj !== "object") return obj; 37 | if (obj instanceof Date || obj instanceof RegExp) return obj; 38 | if (Array.isArray(obj)) return obj.map(walk); 39 | 40 | return Object.keys(obj).reduce((res, key) => { 41 | const camel = camelCase(key); 42 | res[camel] = walk(obj[key]); 43 | return res; 44 | }, {}); 45 | } 46 | 47 | export function camelize(obj: T): T extends string ? string : Camelize { 48 | return typeof obj === "string" ? camelCase(obj) : walk(obj); 49 | } 50 | -------------------------------------------------------------------------------- /src/helpers/parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | init, 3 | __wasm, 4 | type NormalizedStatement, 5 | type ValidatedTable, 6 | type StatementType, 7 | } from "@tableland/sqlparser"; 8 | import { isTestnet } from "./chains.js"; 9 | import { type NameMapping } from "./config.js"; 10 | 11 | export type { NormalizedStatement, StatementType }; 12 | 13 | export async function normalize( 14 | sql: string, 15 | nameMap?: NameMapping 16 | ): Promise { 17 | if (typeof sql !== "string") { 18 | throw new Error("SQL statement must be a String"); 19 | } 20 | /* c8 ignore next 3 */ 21 | if (__wasm == null) { 22 | await init(); 23 | } 24 | return await sqlparser.normalize(sql, nameMap); 25 | } 26 | 27 | export async function validateTableName( 28 | tableName: string, 29 | isCreate = false 30 | ): Promise { 31 | if (typeof tableName !== "string") { 32 | throw new Error("table name must be a String"); 33 | } 34 | /* c8 ignore next 3 */ 35 | if (__wasm == null) { 36 | await init(); 37 | } 38 | return await sqlparser.validateTableName(tableName, isCreate); 39 | } 40 | 41 | export async function validateTables({ 42 | tables, 43 | type, 44 | }: Omit): Promise { 45 | /* c8 ignore next 3 */ 46 | if (__wasm == null) { 47 | await init(); 48 | } 49 | const validatedTables = await Promise.all( 50 | tables.map( 51 | async (tableName) => 52 | await sqlparser.validateTableName(tableName, type === "create") 53 | ) 54 | ); 55 | const same: boolean | null = validatedTables 56 | .map((tbl) => isTestnet(tbl.chainId)) 57 | /* c8 ignore next */ 58 | .reduce((a, b): any => (a === b ? a : null)); 59 | if (same == null) { 60 | throw new Error("network mismatch: mix of testnet and mainnet chains"); 61 | } 62 | return validatedTables; 63 | } 64 | -------------------------------------------------------------------------------- /src/validator/tables.ts: -------------------------------------------------------------------------------- 1 | import { 2 | camelize, 3 | type Camelize, 4 | type PartialRequired, 5 | } from "../helpers/utils.js"; 6 | import { type Signal } from "../helpers/await.js"; 7 | import { hoistApiError } from "./errors.js"; 8 | import { 9 | type Components, 10 | type FetchConfig, 11 | type Paths, 12 | getFetcher, 13 | } from "./client/index.js"; 14 | 15 | export type Params = 16 | Paths["/tables/{chainId}/{tableId}"]["get"]["parameters"]["path"]; 17 | 18 | type Column = Components["schemas"]["Column"]; 19 | type BaseSchema = Components["schemas"]["Schema"]; 20 | interface Schema extends BaseSchema { 21 | readonly columns: Array>; 22 | } 23 | 24 | type Response = Components["schemas"]["Table"]; 25 | interface AssertedResponse 26 | extends PartialRequired { 27 | attributes?: Array>; 28 | schema: Schema; 29 | } 30 | 31 | export interface Table extends Camelize { 32 | attributes?: Array>>; 33 | } 34 | 35 | function assertResponse(obj: Response): obj is AssertedResponse { 36 | return ( 37 | obj.external_url != null && 38 | obj.image != null && 39 | obj.name != null && 40 | obj.schema != null && 41 | obj.schema.columns != null 42 | ); 43 | } 44 | 45 | function transformResponse(obj: Response): Table { 46 | if (assertResponse(obj)) { 47 | const { attributes: _, ...base } = camelize(obj); 48 | return { ...base, attributes: obj.attributes?.map(camelize) }; 49 | } 50 | /* c8 ignore next 2 */ 51 | throw new Error("malformed table repsonse"); 52 | } 53 | 54 | export async function getTable( 55 | config: FetchConfig, 56 | params: Params, 57 | opts: Signal = {} 58 | ): Promise { 59 | const getTableById = getFetcher(config) 60 | .path("/tables/{chainId}/{tableId}") 61 | .method("get") 62 | .create(); 63 | const { data } = await getTableById(params, opts).catch(hoistApiError); 64 | const transformed = transformResponse(data); 65 | return transformed; 66 | } 67 | -------------------------------------------------------------------------------- /src/registry/contract.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type TablelandTables, 3 | TablelandTables__factory as Factory, 4 | } from "@tableland/evm"; 5 | import type { Overrides } from "ethers"; 6 | import { getOverrides, type Signer } from "../helpers/ethers.js"; 7 | import { validateTableName } from "../helpers/parser.js"; 8 | import { getContractAddress } from "../helpers/chains.js"; 9 | 10 | const connect = Factory.connect; 11 | 12 | /** 13 | * TableIdentifier represents the information required to identify a table on the Tableland network. 14 | */ 15 | export interface TableIdentifier { 16 | /** 17 | * The target chain id. 18 | */ 19 | chainId: number; 20 | /** 21 | * The target table id. 22 | */ 23 | tableId: string; 24 | } 25 | 26 | export async function getTableIdentifier( 27 | tableName: string | TableIdentifier 28 | ): Promise { 29 | const { tableId, chainId } = 30 | typeof tableName === "string" 31 | ? await validateTableName(tableName) 32 | : tableName; 33 | return { tableId: tableId.toString(), chainId }; 34 | } 35 | 36 | export async function getContractAndOverrides( 37 | signer: Signer, 38 | chainId: number 39 | ): Promise<{ contract: TablelandTables; overrides: Overrides }> { 40 | const address = getContractAddress(chainId); 41 | signer._checkProvider(); 42 | const contract = connect(address, signer); 43 | const overrides = await getOverrides({ signer }); 44 | return { contract, overrides }; 45 | } 46 | 47 | export function assertChainId(actual: number, expected?: number): number { 48 | if (actual !== expected && expected != null) { 49 | throw new Error( 50 | `chain id mismatch: received ${actual}, expected ${expected}` 51 | ); 52 | } 53 | return actual; 54 | } 55 | 56 | export async function getContractSetup( 57 | signer: Signer, 58 | tableName: string | TableIdentifier 59 | ): Promise<{ 60 | contract: TablelandTables; 61 | overrides: Overrides; 62 | tableId: string; 63 | }> { 64 | const { chainId: chain, tableId } = await getTableIdentifier(tableName); 65 | const chainId = await signer.getChainId(); 66 | assertChainId(chainId, chain); 67 | const { contract, overrides } = await getContractAndOverrides( 68 | signer, 69 | chainId 70 | ); 71 | return { contract, overrides, tableId }; 72 | } 73 | -------------------------------------------------------------------------------- /src/helpers/await.ts: -------------------------------------------------------------------------------- 1 | export type Awaitable = T | PromiseLike; 2 | 3 | export interface Signal { 4 | signal?: AbortSignal; 5 | } 6 | 7 | export interface Interval { 8 | interval?: number; 9 | } 10 | 11 | export type SignalAndInterval = Signal & Interval; 12 | 13 | export interface Wait { 14 | wait: (opts?: SignalAndInterval) => Promise; 15 | } 16 | 17 | export interface AsyncData { 18 | done: boolean; 19 | data?: T; 20 | } 21 | 22 | export type AsyncFunction = () => Awaitable>; 23 | 24 | export function getAbortSignal( 25 | signal?: AbortSignal, 26 | maxTimeout: number = 60_000 27 | ): { 28 | signal: AbortSignal; 29 | timeoutId: ReturnType | undefined; 30 | } { 31 | let abortSignal: AbortSignal; 32 | let timeoutId; 33 | if (signal == null) { 34 | const controller = new AbortController(); 35 | abortSignal = controller.signal; 36 | // return the timeoutId so the caller can cleanup 37 | timeoutId = setTimeout(function () { 38 | controller.abort(); 39 | }, maxTimeout); 40 | } else { 41 | abortSignal = signal; 42 | } 43 | return { signal: abortSignal, timeoutId }; 44 | } 45 | 46 | export async function getAsyncPoller( 47 | fn: AsyncFunction, 48 | interval: number = 1500, 49 | signal?: AbortSignal 50 | ): Promise { 51 | // in order to set a timeout other than 10 seconds you need to 52 | // create and pass in an abort signal with a different timeout 53 | const { signal: abortSignal, timeoutId } = getAbortSignal(signal, 10_000); 54 | const checkCondition = ( 55 | resolve: (value: T) => void, 56 | reject: (reason?: any) => void 57 | ): void => { 58 | Promise.resolve(fn()) 59 | .then((result: AsyncData) => { 60 | if (result.done && result.data != null) { 61 | // We don't want to call `AbortController.abort()` if the call succeeded 62 | clearTimeout(timeoutId); 63 | return resolve(result.data); 64 | } 65 | if (abortSignal.aborted) { 66 | // We don't want to call `AbortController.abort()` if the call is already aborted 67 | clearTimeout(timeoutId); 68 | return reject(abortSignal.reason); 69 | } else { 70 | setTimeout(checkCondition, interval, resolve, reject); 71 | } 72 | }) 73 | .catch((err) => { 74 | return reject(err); 75 | }); 76 | }; 77 | return await new Promise(checkCondition); 78 | } 79 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './test/browser/test', 14 | /* Maximum time one test can run for. */ 15 | timeout: 40 * 1000, 16 | expect: { 17 | /** 18 | * Maximum time expect() should wait for the condition to be met. 19 | * For example in `await expect(locator).toHaveText();` 20 | */ 21 | timeout: 15000 22 | }, 23 | /* Run tests in files in parallel */ 24 | fullyParallel: false, 25 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 26 | forbidOnly: !(process.env.CI == null || process.env.CI === ""), 27 | retries: 0, 28 | workers: 1, 29 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 30 | reporter: 'list', 31 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 32 | use: { 33 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 34 | actionTimeout: 0, 35 | }, 36 | 37 | /* Configure projects for major browsers */ 38 | projects: [ 39 | { 40 | name: 'chromium', 41 | use: { ...devices['Desktop Chrome'] }, 42 | }, 43 | 44 | { 45 | name: 'firefox', 46 | use: { ...devices['Desktop Firefox'] }, 47 | }, 48 | 49 | { 50 | name: 'webkit', 51 | use: { ...devices['Desktop Safari'] }, 52 | }, 53 | 54 | /* Test against mobile viewports. */ 55 | // { 56 | // name: 'Mobile Chrome', 57 | // use: { ...devices['Pixel 5'] }, 58 | // }, 59 | // { 60 | // name: 'Mobile Safari', 61 | // use: { ...devices['iPhone 12'] }, 62 | // }, 63 | 64 | /* Test against branded browsers. */ 65 | // { 66 | // name: 'Microsoft Edge', 67 | // use: { channel: 'msedge' }, 68 | // }, 69 | // { 70 | // name: 'Google Chrome', 71 | // use: { channel: 'chrome' }, 72 | // }, 73 | ], 74 | 75 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 76 | // outputDir: 'test-results/', 77 | 78 | /* Run your local dev server before starting the tests */ 79 | webServer: { 80 | command: 'npm run test-server', 81 | port: 5173, 82 | }, 83 | 84 | globalSetup: "./test/browser/test/setup.js" 85 | }); 86 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | release_version: 6 | description: "Version of this release" 7 | required: true 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 🛎️ 13 | uses: actions/checkout@v2.3.1 14 | 15 | - name: Cache 📦 16 | uses: actions/cache@v1 17 | with: 18 | path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS 19 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 20 | restore-keys: | 21 | ${{ runner.os }}-build-${{ env.cache-name }}- 22 | ${{ runner.os }}-build- 23 | ${{ runner.os }}- 24 | 25 | - name: Setup ⬢ 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: 18 29 | 30 | - name: Tag 🏷️ 31 | uses: actions/github-script@v5 32 | id: create-tag 33 | with: 34 | github-token: ${{secrets.TEXTILEIO_MACHINE_ACCESS_TOKEN}} 35 | script: | 36 | await github.rest.git.createRef({ 37 | owner: context.repo.owner, 38 | repo: context.repo.repo, 39 | ref: 'refs/tags/${{ github.event.inputs.release_version }}', 40 | sha: context.sha 41 | }) 42 | 43 | - name: Version 👍 44 | id: version-bump 45 | uses: jaywcjlove/github-action-package@v1.3.0 46 | with: 47 | version: ${{ github.event.inputs.release_version }} 48 | 49 | - name: Install 🔧 50 | run: npm install 51 | 52 | - name: Conditional ✅ 53 | id: cond 54 | uses: haya14busa/action-cond@v1 55 | with: 56 | cond: ${{ contains(github.event.inputs.release_version, '-') }} 57 | if_true: "next" 58 | if_false: "latest" 59 | 60 | - name: Publish 📦 61 | id: publish 62 | uses: JS-DevTools/npm-publish@v1 63 | with: 64 | token: ${{ secrets.NPM_AUTH_TOKEN }} 65 | tag: ${{ steps.cond.outputs.value }} 66 | access: public 67 | check-version: true 68 | 69 | - name: Release 🚀 70 | if: steps.publish.outputs.type != 'none' 71 | uses: ncipollo/release-action@v1 72 | with: 73 | tag: ${{ steps.publish.outputs.version }} 74 | generateReleaseNotes: true 75 | prerelease: ${{ contains(steps.publish.outputs.type, 'pre') }} 76 | token: ${{ secrets.GITHUB_TOKEN }} 77 | -------------------------------------------------------------------------------- /test/await.test.ts: -------------------------------------------------------------------------------- 1 | import { match, rejects, strictEqual } from "assert"; 2 | import { describe, test } from "mocha"; 3 | import { 4 | type AsyncFunction, 5 | getAbortSignal, 6 | getAsyncPoller, 7 | type SignalAndInterval, 8 | } from "../src/helpers/await.js"; 9 | import { getDelay } from "../src/helpers/utils.js"; 10 | 11 | describe("await", function () { 12 | test("ensure polling stops after successful results", async function () { 13 | let callCount = 0; 14 | async function testPolling({ 15 | signal, 16 | interval, 17 | }: SignalAndInterval = {}): Promise { 18 | let first = true; 19 | const fn: AsyncFunction = async () => { 20 | callCount += 1; 21 | if (first) { 22 | first = false; 23 | return { done: false }; 24 | } 25 | await getDelay(10); 26 | return { done: true, data: true }; 27 | }; 28 | return await getAsyncPoller(fn, interval, signal); 29 | } 30 | const controller = new AbortController(); 31 | const signal = controller.signal; 32 | await testPolling({ signal, interval: 10 }); 33 | await getDelay(1000); 34 | strictEqual(callCount, 2); 35 | }); 36 | 37 | test("ensure polling stops after timeout", async function () { 38 | async function testPolling({ 39 | signal, 40 | interval, 41 | }: SignalAndInterval = {}): Promise { 42 | const fn: AsyncFunction = async () => { 43 | return { done: false }; 44 | }; 45 | return await getAsyncPoller(fn, interval, signal); 46 | } 47 | const controller = new AbortController(); 48 | const signal = controller.signal; 49 | setTimeout(() => controller.abort(), 5); 50 | await rejects(testPolling({ signal, interval: 10 }), (err: any) => { 51 | match(err.message, /Th(e|is) operation was aborted/); 52 | return true; 53 | }); 54 | }); 55 | 56 | test("getAbortSignal returns a valid signal", async function () { 57 | const controller = new AbortController(); 58 | const initial = controller.signal; 59 | const { signal } = getAbortSignal(initial, 10); 60 | strictEqual(signal.aborted, false); 61 | controller.abort(); 62 | strictEqual(signal.aborted, true); 63 | strictEqual(initial.aborted, true); 64 | const { signal: third } = getAbortSignal(undefined, 10); 65 | strictEqual(third.aborted, false); 66 | await new Promise(function (resolve) { 67 | third.addEventListener("abort", function abortListener() { 68 | third.removeEventListener("abort", abortListener); 69 | resolve(); 70 | }); 71 | }); 72 | strictEqual(third.aborted, true); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/validator/receipt.ts: -------------------------------------------------------------------------------- 1 | import { 2 | camelize, 3 | type Camelize, 4 | type PartialRequired, 5 | } from "../helpers/utils.js"; 6 | import { 7 | type AsyncFunction, 8 | type Signal, 9 | type SignalAndInterval, 10 | getAsyncPoller, 11 | } from "../helpers/await.js"; 12 | import { hoistApiError } from "./errors.js"; 13 | import { 14 | type Components, 15 | type FetchConfig, 16 | type Paths, 17 | getFetcher, 18 | ApiError, 19 | } from "./client/index.js"; 20 | 21 | export type Params = 22 | Paths["/receipt/{chainId}/{transactionHash}"]["get"]["parameters"]["path"]; 23 | 24 | type Response = Components["schemas"]["TransactionReceipt"]; 25 | type AssertedResponse = PartialRequired; 26 | export type TransactionReceipt = Camelize; 27 | 28 | function assertResponse(obj: Response): obj is AssertedResponse { 29 | return ( 30 | obj.block_number != null && 31 | obj.chain_id != null && 32 | obj.transaction_hash != null && 33 | /* c8 ignore next */ 34 | (obj.table_id != null || obj.error != null || obj.error_event_idx != null) 35 | ); 36 | } 37 | 38 | function transformResponse(obj: Response): TransactionReceipt { 39 | if (assertResponse(obj)) { 40 | return camelize(obj); 41 | } 42 | /* c8 ignore next 2 */ 43 | throw new Error("malformed transaction receipt response"); 44 | } 45 | 46 | export async function getTransactionReceipt( 47 | config: FetchConfig, 48 | params: Params, 49 | opts: Signal = {} 50 | ): Promise { 51 | const receiptByTransactionHash = getFetcher(config) 52 | .path("/receipt/{chainId}/{transactionHash}") 53 | .method("get") 54 | .create(); 55 | const { data } = await receiptByTransactionHash(params, opts).catch( 56 | hoistApiError 57 | ); 58 | const transformed = transformResponse(data); 59 | return transformed; 60 | } 61 | 62 | export async function pollTransactionReceipt( 63 | config: FetchConfig, 64 | params: Params, 65 | { signal, interval }: SignalAndInterval = {} 66 | ): Promise { 67 | const receiptByTransactionHash = getFetcher(config) 68 | .path("/receipt/{chainId}/{transactionHash}") 69 | .method("get") 70 | .create(); 71 | const fn: AsyncFunction = async () => { 72 | try { 73 | const { data: obj } = await receiptByTransactionHash(params, { 74 | signal, 75 | }).catch(hoistApiError); 76 | const data = transformResponse(obj); 77 | return { done: true, data }; 78 | } catch (err) { 79 | if (err instanceof ApiError && err.status === 404) { 80 | return { done: false }; 81 | } 82 | /* c8 ignore next */ 83 | throw err; 84 | } 85 | }; 86 | const receipt = await getAsyncPoller(fn, interval, signal); 87 | return receipt; 88 | } 89 | -------------------------------------------------------------------------------- /test/binding.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual } from "assert"; 2 | import { describe, test } from "mocha"; 3 | import { 4 | getParameters, 5 | bindValues, 6 | type ValuesType, 7 | type Parameters, 8 | placeholderRegExp, 9 | } from "../src/helpers/binding.js"; 10 | 11 | describe("binding", function () { 12 | let parameters: Parameters; 13 | 14 | describe("placeholderRegExp", function () { 15 | test("where the regexp captures and doesn't capture the right things", function () { 16 | const inputString = 17 | "INSERT INTO [peop?le] VALUES (@name, ?, :name, `:name`, ?,'?', ?4, \"?3\", ?, '\\'?')"; 18 | // Grab all hits, extract the matches, and filter on if it was a capture or not 19 | const matches = Array.from(inputString.matchAll(placeholderRegExp)) 20 | .map(([_, match]) => match) 21 | .filter(Boolean); 22 | // We only want things that weren't escaped 23 | deepStrictEqual(matches, ["@name", "?", ":name", "?", "?4", "?"]); 24 | }); 25 | }); 26 | describe("getParameters()", function () { 27 | test("where all combinations of input parameters are used", function () { 28 | const values: ValuesType[] = [ 29 | 45, 30 | { name: "Hen'ry" }, 31 | [54, true, Uint8Array.from([1, 2, 3])], 32 | null, 33 | ]; 34 | parameters = getParameters(...values); 35 | const expected: Parameters = { 36 | anon: [45, 54, true, Uint8Array.from([1, 2, 3]), null], 37 | named: { name: "Hen'ry" }, 38 | }; 39 | deepStrictEqual(parameters, expected); 40 | }); 41 | }); 42 | describe("bindValues()", function () { 43 | test("where multiple combinations of placeholders are used", function () { 44 | const sql = 45 | "INSERT INTO people VALUES (@name, ?, :name, ?, '?', ?4, ?3, ?)"; 46 | const actual = bindValues(sql, parameters); 47 | const expected = 48 | "INSERT INTO people VALUES ('Hen''ry', 45, 'Hen''ry', 54, '?', X'010203', 1, NULL)"; 49 | deepStrictEqual(actual, expected); 50 | }); 51 | 52 | test("where all supported data types are used", function () { 53 | class RowId { 54 | toSQL(): string { 55 | return "_rowid_"; 56 | } 57 | } 58 | const now = new Date(); 59 | const anon = [ 60 | { some: "json" }, // json 61 | "string", // string 62 | true, // boolean 63 | 42, // int 64 | BigInt(100), // bigint 65 | 3.14, // real 66 | now, // date 67 | Uint8Array.from([1, 2, 3]), // bytes 68 | null, // null 69 | undefined, // undefined 70 | new RowId(), // toSQL, 71 | () => "An arbitrary function", // toString will be used 72 | ]; 73 | const sql = 74 | "INSERT INTO people VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; 75 | const actual = bindValues(sql, { anon }); 76 | const expected = 77 | `INSERT INTO people VALUES (` + 78 | `'{"some":"json"}', 'string', 1, 42, 100, 3.14, ` + 79 | `${now.valueOf()}, X'010203', NULL, NULL, _rowid_, ` + 80 | `'() => "An arbitrary function"')`; 81 | deepStrictEqual(actual, expected); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 20 | 21 | ## Summary 22 | 23 | 32 | 33 | ## Details 34 | 35 | 44 | 45 | ## How it was tested 46 | 47 | 52 | 53 | 56 | 57 | ## Checklist: 58 | 59 | - [ ] My code follows the style guidelines of this project 60 | - [ ] I have performed a self-review of my own code 61 | - [ ] I have commented my code, particularly in hard-to-understand areas 62 | - [ ] I have made corresponding changes to the documentation 63 | - [ ] My changes generate no new warnings 64 | - [ ] I have added tests that prove my fix is effective or that my feature works 65 | - [ ] New and existing unit tests pass locally with my changes 66 | - [ ] Any dependent changes have been merged and published in downstream modules 67 | -------------------------------------------------------------------------------- /test/browser/server/src/App.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 109 | 110 | -------------------------------------------------------------------------------- /src/helpers/config.ts: -------------------------------------------------------------------------------- 1 | import { type WaitableTransactionReceipt } from "../registry/utils.js"; 2 | import { type ChainName, getBaseUrl } from "./chains.js"; 3 | import { type Signer, type ExternalProvider, getSigner } from "./ethers.js"; 4 | 5 | export interface ReadConfig { 6 | baseUrl: string; 7 | aliases?: AliasesNameMap; 8 | } 9 | 10 | export interface SignerConfig { 11 | signer: Signer; 12 | } 13 | 14 | export interface AutoWaitConfig { 15 | autoWait: boolean; 16 | } 17 | 18 | export type Config = Partial; 19 | 20 | export type NameMapping = Record; 21 | 22 | export interface AliasesNameMap { 23 | read: () => Promise; 24 | write: (map: NameMapping) => Promise; 25 | } 26 | 27 | export async function checkWait( 28 | config: Config & Partial, 29 | receipt: WaitableTransactionReceipt 30 | ): Promise { 31 | if (config.autoWait ?? false) { 32 | const waited = await receipt.wait(); 33 | return { ...receipt, ...waited }; 34 | } 35 | return receipt; 36 | } 37 | 38 | export async function extractBaseUrl( 39 | conn: Config = {}, 40 | chainNameOrId?: ChainName | number 41 | ): Promise { 42 | if (conn.baseUrl == null) { 43 | if (conn.signer == null) { 44 | if (chainNameOrId == null) { 45 | throw new Error( 46 | "missing connection information: baseUrl, signer, or chainId required" 47 | ); 48 | } 49 | return getBaseUrl(chainNameOrId); 50 | } 51 | const chainId = await conn.signer.getChainId(); 52 | return getBaseUrl(chainId); 53 | } 54 | return conn.baseUrl; 55 | } 56 | 57 | export async function extractSigner( 58 | conn: Config = {}, 59 | external?: ExternalProvider 60 | ): Promise { 61 | if (conn.signer == null) { 62 | return await getSigner(external); 63 | } 64 | return conn.signer; 65 | } 66 | 67 | export async function extractChainId(conn: Config = {}): Promise { 68 | const signer = await extractSigner(conn); 69 | const chainId = await signer.getChainId(); 70 | 71 | if (chainId === 0 || isNaN(chainId) || chainId == null) { 72 | /* c8 ignore next 4 */ 73 | throw new Error( 74 | "cannot find chainId: is your signer connected to a network?" 75 | ); 76 | } 77 | 78 | return chainId; 79 | } 80 | 81 | const findOrCreateFile = async function (filepath: string): Promise { 82 | const fs = await getFsModule(); 83 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 84 | if (!fs.existsSync(filepath)) { 85 | fs.writeFileSync(filepath, JSON.stringify({})); 86 | } 87 | 88 | return fs.readFileSync(filepath); 89 | }; 90 | 91 | // TODO: next major we should remove the jsonFileAliases helper and expose it 92 | // in a different package since it doesn't work in the browser. 93 | const getFsModule = (function () { 94 | let fs: any; 95 | return async function () { 96 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 97 | if (fs) return fs; 98 | 99 | fs = await import(/* webpackIgnore: true */ "fs"); 100 | return fs; 101 | }; 102 | })(); 103 | 104 | export function jsonFileAliases(filepath: string): AliasesNameMap { 105 | return { 106 | read: async function (): Promise { 107 | const jsonBuf = await findOrCreateFile(filepath); 108 | return JSON.parse(jsonBuf.toString()); 109 | }, 110 | write: async function (nameMap: NameMapping) { 111 | const fs = await getFsModule(); 112 | fs.writeFileSync(filepath, JSON.stringify(nameMap)); 113 | }, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /src/registry/run.ts: -------------------------------------------------------------------------------- 1 | import { type SignerConfig } from "../helpers/config.js"; 2 | import { type ContractTransaction } from "../helpers/ethers.js"; 3 | import { validateTableName } from "../helpers/parser.js"; 4 | import { 5 | getContractAndOverrides, 6 | type TableIdentifier, 7 | assertChainId, 8 | } from "./contract.js"; 9 | 10 | export interface PrepareParams { 11 | /** 12 | * SQL statement string. 13 | */ 14 | statement: string; 15 | /** 16 | * The target chain id. 17 | */ 18 | chainId: number; 19 | /** 20 | * The first table name in a series of SQL statements. 21 | */ 22 | first: string; 23 | } 24 | 25 | export async function prepareMutateOne({ 26 | statement, 27 | chainId, 28 | first, 29 | }: PrepareParams): Promise { 30 | const { tableId, prefix, chainId: chain } = await validateTableName(first); 31 | assertChainId(chain, chainId); 32 | return { tableId: tableId.toString(), statement, prefix, chainId }; 33 | } 34 | 35 | /** 36 | * @custom:deprecated This type will change in the next major version. 37 | * Use the `MutateOneParams` type. 38 | */ 39 | export interface RunSQLParams extends TableIdentifier { 40 | /** 41 | * SQL statement string. 42 | */ 43 | statement: string; 44 | } 45 | 46 | export interface MutateOneParams extends TableIdentifier { 47 | /** 48 | * SQL statement string. 49 | */ 50 | statement: string; 51 | } 52 | 53 | /** 54 | * Runnable represents an Object the will be used as a Runnable struct in the 55 | * call to the contract's `mutate` function. 56 | * @typedef {Object} Runnable 57 | * @property {string} statement - SQL statement string. 58 | * @property {number} chainId - The target chain id. 59 | */ 60 | export interface Runnable { 61 | statement: string; 62 | tableId: number; 63 | } 64 | 65 | /** 66 | * MutateManyParams Represents the parameters Object used to mutate multiple tables in a single tx. 67 | * @typedef {Object} MutateManyParams 68 | * @property {Runnable[]} runnables - List of Runnables. 69 | * @property {number} chainId - The target chain id. 70 | */ 71 | export interface MutateManyParams { 72 | runnables: Runnable[]; 73 | chainId: number; 74 | } 75 | 76 | export type MutateParams = MutateOneParams | MutateManyParams; 77 | 78 | export async function mutate( 79 | config: SignerConfig, 80 | params: MutateParams 81 | ): Promise { 82 | if (isMutateOne(params)) { 83 | return await _mutateOne(config, params); 84 | } 85 | 86 | return await _mutateMany(config, params); 87 | } 88 | 89 | async function _mutateOne( 90 | { signer }: SignerConfig, 91 | { statement, tableId, chainId }: MutateOneParams 92 | ): Promise { 93 | const caller = await signer.getAddress(); 94 | const { contract, overrides } = await getContractAndOverrides( 95 | signer, 96 | chainId 97 | ); 98 | return await contract["mutate(address,uint256,string)"]( 99 | caller, 100 | tableId, 101 | statement, 102 | overrides 103 | ); 104 | } 105 | 106 | async function _mutateMany( 107 | { signer }: SignerConfig, 108 | { runnables, chainId }: MutateManyParams 109 | ): Promise { 110 | const caller = await signer.getAddress(); 111 | const { contract, overrides } = await getContractAndOverrides( 112 | signer, 113 | chainId 114 | ); 115 | return await contract["mutate(address,(uint256,string)[])"]( 116 | caller, 117 | runnables, 118 | overrides 119 | ); 120 | } 121 | 122 | const isMutateOne = function (params: MutateParams): params is MutateOneParams { 123 | return (params as MutateOneParams).statement !== undefined; 124 | }; 125 | -------------------------------------------------------------------------------- /test/config.test.ts: -------------------------------------------------------------------------------- 1 | import { rejects, strictEqual } from "assert"; 2 | import { describe, test } from "mocha"; 3 | import { getAccounts } from "@tableland/local"; 4 | import { 5 | extractBaseUrl, 6 | extractSigner, 7 | type ReadConfig, 8 | type SignerConfig, 9 | type Config, 10 | } from "../src/helpers/config.js"; 11 | import { 12 | getDefaultProvider, 13 | type ExternalProvider, 14 | getChainId, 15 | } from "../src/helpers/index.js"; 16 | 17 | describe("config", function () { 18 | describe("extractBaseUrl()", function () { 19 | test("where baseUrl is explicitly provided", async function () { 20 | const conn: ReadConfig = { baseUrl: "baseUrl" }; 21 | const extracted = await extractBaseUrl(conn); 22 | strictEqual(extracted, "baseUrl"); 23 | }); 24 | 25 | test("where baseUrl is obtained via the chainId", async function () { 26 | const [, wallet] = getAccounts(); 27 | const signer = wallet.connect( 28 | getDefaultProvider("http://127.0.0.1:8545") 29 | ); 30 | const conn: SignerConfig = { signer }; 31 | const extracted = await extractBaseUrl(conn); 32 | strictEqual(extracted, "http://localhost:8080/api/v1"); 33 | }); 34 | 35 | test("where baseUrl is obtained via a fallback chainId", async function () { 36 | const chainNameOrId = getChainId("localhost"); 37 | const conn: Config = {}; 38 | const extracted = await extractBaseUrl(conn, chainNameOrId); 39 | strictEqual(extracted, "http://localhost:8080/api/v1"); 40 | }); 41 | 42 | test("where baseUrl cannot be extracted", async function () { 43 | const conn: Config = {}; 44 | await rejects(extractBaseUrl(conn), (err: any) => { 45 | strictEqual( 46 | err.message, 47 | "missing connection information: baseUrl, signer, or chainId required" 48 | ); 49 | return true; 50 | }); 51 | }); 52 | }); 53 | describe("extractSigner()", function () { 54 | test("where signer is explicitly provided", async function () { 55 | const [, wallet] = getAccounts(); 56 | const signer = wallet.connect(getDefaultProvider()); 57 | const conn: SignerConfig = { signer }; 58 | const extracted = await extractSigner(conn); 59 | strictEqual(await extracted.getAddress(), wallet.address); 60 | }); 61 | 62 | test("where signer is obtained via an external provider", async function () { 63 | const conn: Config = {}; 64 | const external = { 65 | request: async (request: { 66 | method: string; 67 | params?: any[]; 68 | }): Promise => {}, 69 | }; 70 | const extracted = await extractSigner(conn, external); 71 | strictEqual(extracted._isSigner, true); 72 | }); 73 | 74 | test("where signer is obtained via an external provider and it fails", async function () { 75 | const conn: Config = {}; 76 | const external = {}; 77 | await rejects(extractSigner(conn, external), (err: any) => { 78 | strictEqual( 79 | err.message, 80 | "provider error: missing request method on ethereum provider" 81 | ); 82 | return true; 83 | }); 84 | }); 85 | 86 | test("where signer is obtained via an injected provider", async function () { 87 | const conn: Config = {}; 88 | const ethereum: ExternalProvider = { 89 | request: async (request: { 90 | method: string; 91 | params?: any[]; 92 | }): Promise => {}, 93 | }; 94 | (globalThis as any).ethereum = ethereum; 95 | const extracted = await extractSigner(conn); 96 | extracted._checkProvider(); 97 | strictEqual(extracted._isSigner, true); 98 | delete (globalThis as any).ethereum; 99 | }); 100 | 101 | test("where signer cannot be extracted", async function () { 102 | const conn: Config = {}; 103 | await rejects(extractSigner(conn), (err: any) => { 104 | strictEqual( 105 | err.message, 106 | "provider error: missing global ethereum provider" 107 | ); 108 | return true; 109 | }); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /src/registry/create.ts: -------------------------------------------------------------------------------- 1 | import { normalize } from "../helpers/index.js"; 2 | import { type SignerConfig } from "../helpers/config.js"; 3 | import { type ContractTransaction } from "../helpers/ethers.js"; 4 | import { validateTableName } from "../helpers/parser.js"; 5 | import { getContractAndOverrides } from "./contract.js"; 6 | 7 | // Match _anything_ between create table and schema portion of create statement (statement must be a single line) 8 | const firstSearch = 9 | /(?^CREATE\s+TABLE\s+)(?\S+)(?\s*\(.*\)[;]?$)/i; 10 | const escapeChars = /"|'|`|\]|\[/; 11 | 12 | export interface PrepareParams { 13 | /** 14 | * SQL statement string. 15 | */ 16 | statement: string; 17 | /** 18 | * The target chain id. 19 | */ 20 | chainId: number; 21 | /** 22 | * The first table name in a series of SQL statements. 23 | */ 24 | first?: string; 25 | } 26 | 27 | export async function prepareCreateOne({ 28 | statement, 29 | chainId, 30 | first, 31 | }: PrepareParams): Promise { 32 | if (first == null) { 33 | const normalized = await normalize(statement); 34 | first = normalized.tables[0]; 35 | } 36 | 37 | const { prefix, name: tableName } = await validateTableName( 38 | `${first}_${chainId}`, 39 | true 40 | ); 41 | const stmt = statement 42 | .replace(/\n/g, "") 43 | .replace(/\r/g, "") 44 | .replace( 45 | firstSearch, 46 | function (_, create: string, name: string, schema: string) { 47 | // If this name has any escape chars, escape the whole thing. 48 | const newName = escapeChars.test(name) ? `[${tableName}]` : tableName; 49 | return `${create.trim()} ${newName.trim()} ${schema.trim()}`; 50 | } 51 | ); 52 | 53 | return { statement: stmt, chainId, prefix }; 54 | } 55 | 56 | /** 57 | * CreateOneParams Represents the parameters Object used to create a single table. 58 | * @typedef {Object} CreateOneParams 59 | * @property {string} statement - SQL statement string. 60 | * @property {number} chainId - The target chain id. 61 | */ 62 | export interface CreateOneParams { 63 | statement: string; 64 | chainId: number; 65 | } 66 | 67 | /** 68 | * CreateManyParams Represents the parameters Object used to create multiple tables in a single tx. 69 | * @typedef {Object} CreateManyParams 70 | * @property {string[]} statements - List of create SQL statement strings. 71 | * @property {number} chainId - The target chain id. 72 | */ 73 | export interface CreateManyParams { 74 | statements: string[]; 75 | chainId: number; 76 | } 77 | 78 | export type CreateParams = CreateOneParams | CreateManyParams; 79 | 80 | /** 81 | * @custom deprecated This be removed in the next major version. 82 | * Use `create`. 83 | */ 84 | export async function createTable( 85 | config: SignerConfig, 86 | params: CreateOneParams 87 | ): Promise { 88 | return await _createOne(config, params); 89 | } 90 | 91 | export async function create( 92 | config: SignerConfig, 93 | params: CreateParams 94 | ): Promise { 95 | if (isCreateOne(params)) { 96 | return await _createOne(config, params); 97 | } 98 | return await _createMany(config, params); 99 | } 100 | 101 | async function _createOne( 102 | { signer }: SignerConfig, 103 | { statement, chainId }: CreateOneParams 104 | ): Promise { 105 | const owner = await signer.getAddress(); 106 | const { contract, overrides } = await getContractAndOverrides( 107 | signer, 108 | chainId 109 | ); 110 | return await contract["create(address,string)"](owner, statement, overrides); 111 | } 112 | 113 | async function _createMany( 114 | { signer }: SignerConfig, 115 | { statements, chainId }: CreateManyParams 116 | ): Promise { 117 | const owner = await signer.getAddress(); 118 | const { contract, overrides } = await getContractAndOverrides( 119 | signer, 120 | chainId 121 | ); 122 | return await contract["create(address,string[])"]( 123 | owner, 124 | statements, 125 | overrides 126 | ); 127 | } 128 | 129 | const isCreateOne = function (params: CreateParams): params is CreateOneParams { 130 | return (params as CreateOneParams).statement !== undefined; 131 | }; 132 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tableland/sdk", 3 | "version": "0.0.0", 4 | "description": "A database client and helpers for the Tableland network", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "type": "module", 9 | "main": "./dist/cjs/index.js", 10 | "module": "./dist/esm/index.js", 11 | "types": "./dist/esm/index.d.ts", 12 | "exports": { 13 | ".": { 14 | "require": "./dist/cjs/index.js", 15 | "import": "./dist/esm/index.js", 16 | "default": "./dist/esm/index.js" 17 | }, 18 | "./helpers": { 19 | "require": "./dist/cjs/helpers/index.js", 20 | "import": "./dist/esm/helpers/index.js", 21 | "default": "./dist/esm/helpers/index.js" 22 | }, 23 | "./registry": { 24 | "require": "./dist/cjs/registry/index.js", 25 | "import": "./dist/esm/registry/index.js", 26 | "default": "./dist/esm/registry/index.js" 27 | }, 28 | "./validator": { 29 | "require": "./dist/cjs/validator/index.js", 30 | "import": "./dist/esm/validator/index.js", 31 | "default": "./dist/esm/validator/index.js" 32 | }, 33 | "./database": { 34 | "require": "./dist/cjs/database.js", 35 | "import": "./dist/esm/database.js", 36 | "default": "./dist/esm/database.js" 37 | }, 38 | "./statement": { 39 | "require": "./dist/cjs/statement.js", 40 | "import": "./dist/esm/statement.js", 41 | "default": "./dist/esm/statement.js" 42 | }, 43 | "./**/*.js": { 44 | "require": "./dist/cjs/*", 45 | "import": "./dist/esm/*", 46 | "default": "./dist/esm/*" 47 | } 48 | }, 49 | "files": [ 50 | "dist/**/*.js?(.map)", 51 | "dist/**/*.d.ts", 52 | "dist/**/package.json", 53 | "src/**/*.ts" 54 | ], 55 | "scripts": { 56 | "lint": "eslint '**/*.{js,ts}'", 57 | "lint:fix": "npm run lint -- --fix", 58 | "prettier": "prettier '**/*.{ts,json,sol,md}' --check", 59 | "prettier:fix": "npm run prettier -- --write", 60 | "format": "npm run prettier:fix && npm run lint:fix", 61 | "prepublishOnly": "npm run build", 62 | "test": "mocha", 63 | "test:browser": "PW_TS_ESM_ON=true playwright test", 64 | "test:browser-show-report": "npx playwright show-report", 65 | "test-server": "cd test/browser/server && npm install && npm run dev", 66 | "coverage": "c8 --exclude src/validator/client/fetcher.ts --exclude test mocha --exit", 67 | "docs": "typedoc --entryPoints src/index.ts", 68 | "clean": "rm -rf dist coverage docs", 69 | "build:api": "npx openapi-typescript https://raw.githubusercontent.com/tablelandnetwork/docs/main/specs/validator/tableland-openapi-spec.yaml --output src/validator/client/validator.ts --immutable-types --path-params-as-types", 70 | "build:esm": "npx tsc", 71 | "build:cjs": "npx tsc -p tsconfig.cjs.json", 72 | "build": "npm run build:api && npm run build:esm && npm run build:cjs && ./fixup" 73 | }, 74 | "keywords": [ 75 | "tableland", 76 | "sql", 77 | "ethereum", 78 | "database" 79 | ], 80 | "license": "MIT AND Apache-2.0", 81 | "devDependencies": { 82 | "@databases/escape-identifier": "^1.0.3", 83 | "@databases/sql": "^3.2.0", 84 | "@ethersproject/experimental": "^5.7.0", 85 | "@playwright/test": "^1.30.0", 86 | "@tableland/local": "^2.0.1", 87 | "@types/assert": "^1.5.6", 88 | "@types/mocha": "^10.0.1", 89 | "@types/node": "^20.1.4", 90 | "@typescript-eslint/eslint-plugin": "^5.62.0", 91 | "@typescript-eslint/parser": "^5.62.0", 92 | "assert": "^2.0.0", 93 | "c8": "^8.0.0", 94 | "d1-orm": "^0.7.1", 95 | "eslint": "^8.31.0", 96 | "eslint-config-prettier": "^8.6.0", 97 | "eslint-config-standard": "^17.0.0", 98 | "eslint-config-standard-with-typescript": "^35.0.0", 99 | "eslint-plugin-import": "^2.26.0", 100 | "eslint-plugin-n": "^15.6.0", 101 | "eslint-plugin-promise": "^6.1.1", 102 | "mocha": "^10.2.0", 103 | "openapi-typescript": "^6.1.0", 104 | "prettier": "^2.8.2", 105 | "ts-node": "^10.9.1", 106 | "typedoc": "^0.24.6", 107 | "typescript": "^5.0.4" 108 | }, 109 | "dependencies": { 110 | "@async-generators/from-emitter": "^0.3.0", 111 | "@tableland/evm": "^4.3.0", 112 | "@tableland/sqlparser": "^1.3.0", 113 | "ethers": "^5.7.2" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /test/chains.test.ts: -------------------------------------------------------------------------------- 1 | import { strictEqual, throws } from "assert"; 2 | import { describe, test } from "mocha"; 3 | import { 4 | getBaseUrl, 5 | getChainInfo, 6 | getContractAddress, 7 | getChainId, 8 | isTestnet, 9 | type ChainName, 10 | supportedChains, 11 | overrideDefaults, 12 | } from "../src/helpers/chains.js"; 13 | 14 | describe("chains", function () { 15 | describe("getBaseUrl()", function () { 16 | test("where we check some of the known defaults", function () { 17 | // We don't require a specific set because we don't want to have to update 18 | // these tests every time 19 | const localhost = "http://localhost:8080/api/v1"; 20 | const testnets = "https://testnets.tableland.network/api/v1"; 21 | const mainnet = "https://tableland.network/api/v1"; 22 | strictEqual(getBaseUrl("localhost"), localhost); 23 | strictEqual(getBaseUrl("maticmum"), testnets); 24 | strictEqual(getBaseUrl("matic"), mainnet); 25 | strictEqual(getBaseUrl("optimism"), mainnet); 26 | strictEqual(getBaseUrl("mainnet"), mainnet); 27 | }); 28 | 29 | test("where we check the known default localhost contract address", async function () { 30 | const localhost = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"; 31 | const matic = "0x5c4e6A9e5C1e1BF445A062006faF19EA6c49aFeA"; 32 | // Note we're checking local-tableland here, rather than localhost 33 | strictEqual( 34 | getContractAddress("local-tableland").toLowerCase(), 35 | localhost.toLowerCase() 36 | ); 37 | strictEqual( 38 | getContractAddress("matic").toLowerCase(), 39 | matic.toLowerCase() 40 | ); 41 | }); 42 | 43 | test("where we make sure a testnet is correctly flagged", function () { 44 | const testnets: ChainName[] = [ 45 | "sepolia", 46 | "arbitrum-goerli", 47 | "maticmum", 48 | "optimism-goerli", 49 | "local-tableland", 50 | "localhost", 51 | ]; 52 | const mainnets: ChainName[] = [ 53 | "mainnet", 54 | "arbitrum", 55 | "matic", 56 | "optimism", 57 | ]; 58 | for (const net of testnets) { 59 | strictEqual(isTestnet(net), true); 60 | } 61 | for (const net of mainnets) { 62 | strictEqual(isTestnet(net), false); 63 | } 64 | }); 65 | 66 | test("where we make sure supportedChains is a valid object", function () { 67 | strictEqual(Object.keys(supportedChains).length >= 12, true); 68 | strictEqual(Object.keys(supportedChains).includes("mainnet"), true); 69 | strictEqual(Object.keys(supportedChains).includes("maticmum"), true); 70 | strictEqual(Object.keys(supportedChains).includes("localhost"), true); 71 | }); 72 | 73 | test("where ensure we have a default set of chains with ids", function () { 74 | strictEqual(getChainId("mainnet"), 1); 75 | strictEqual(getChainId("localhost"), 31337); 76 | strictEqual(getChainId("maticmum"), 80001); 77 | strictEqual(getChainId("optimism"), 10); 78 | strictEqual(getChainId("arbitrum"), 42161); 79 | }); 80 | 81 | test("where spot check a few chain info objects", function () { 82 | const localhostUrl = "http://localhost:8080/api/v1"; 83 | const testnetsUrl = "https://testnets.tableland.network/api/v1"; 84 | const mainnetUrl = "https://tableland.network/api/v1"; 85 | 86 | const mainnet = getChainInfo("mainnet"); 87 | strictEqual(mainnet.baseUrl, mainnetUrl); 88 | strictEqual(mainnet.chainId, 1); 89 | const localhost = getChainInfo("localhost"); 90 | strictEqual(localhost.baseUrl, localhostUrl); 91 | strictEqual(localhost.chainId, 31337); 92 | const maticmum = getChainInfo("maticmum"); 93 | strictEqual(maticmum.baseUrl, testnetsUrl); 94 | strictEqual(maticmum.chainId, 80001); 95 | }); 96 | }); 97 | 98 | describe("overrideDefaults()", function () { 99 | test("when called incorrectly", async function () { 100 | throws( 101 | // @ts-expect-error need to tell ts to ignore this since we are testing a failure when used without ts 102 | () => overrideDefaults("homestead"), // didn't pass in overrides 103 | (err: any) => { 104 | strictEqual(err.message, "override values must be an Object"); 105 | return true; 106 | } 107 | ); 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/validator/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Signal, 3 | type SignalAndInterval, 4 | type ReadConfig, 5 | type ChainName, 6 | getBaseUrl, 7 | } from "../helpers/index.js"; 8 | import { getHealth } from "./health.js"; 9 | import { getVersion, type Version } from "./version.js"; 10 | import { getTable, type Table, type Params as TableParams } from "./tables.js"; 11 | import { 12 | getQuery, 13 | type Params as QueryParams, 14 | type Format, 15 | type TableFormat, 16 | type ObjectsFormat, 17 | } from "./query.js"; 18 | import { 19 | getTransactionReceipt, 20 | pollTransactionReceipt, 21 | type TransactionReceipt, 22 | type Params as ReceiptParams, 23 | } from "./receipt.js"; 24 | 25 | export { ApiError } from "./client/index.js"; 26 | export { 27 | type TransactionReceipt, 28 | type Table, 29 | type TableFormat, 30 | type ObjectsFormat, 31 | type QueryParams, 32 | }; 33 | 34 | /** 35 | * Validator provides direct access to remote Validator REST APIs. 36 | */ 37 | export class Validator { 38 | readonly config: ReadConfig; 39 | /** 40 | * Create a Validator instance with the specified connection configuration. 41 | * @param config The connection configuration. This must include a baseUrl 42 | * string. If passing the config from a pre-existing Database instance, it 43 | * must have a non-null baseUrl key defined. 44 | */ 45 | constructor(config: Partial = {}) { 46 | /* c8 ignore next 3 */ 47 | if (config.baseUrl == null) { 48 | throw new Error("missing baseUrl information"); 49 | } 50 | this.config = config as ReadConfig; 51 | } 52 | 53 | /** 54 | * Create a new Validator instance that uses the default baseUrl for a given chain. 55 | * @param chainNameOrId The name or id of the chain to target. 56 | * @returns A Validator with a default baseUrl. 57 | */ 58 | static forChain(chainNameOrId: ChainName | number): Validator { 59 | const baseUrl = getBaseUrl(chainNameOrId); 60 | return new Validator({ baseUrl }); 61 | } 62 | 63 | /** 64 | * Get health status 65 | * @description Returns OK if the validator considers itself healthy 66 | */ 67 | async health(opts: Signal = {}): Promise { 68 | return await getHealth(this.config, opts); 69 | } 70 | 71 | /** 72 | * Get version information 73 | * @description Returns version information about the validator daemon 74 | */ 75 | async version(opts: Signal = {}): Promise { 76 | return await getVersion(this.config, opts); 77 | } 78 | 79 | /** 80 | * Get table information 81 | * @description Returns information about a single table, including schema information 82 | */ 83 | async getTableById(params: TableParams, opts: Signal = {}): Promise
{ 84 | if ( 85 | typeof params.chainId !== "number" || 86 | typeof params.tableId !== "string" 87 | ) { 88 | throw new Error("cannot get table with invalid chain or table id"); 89 | } 90 | return await getTable(this.config, params); 91 | } 92 | 93 | /** 94 | * Query the network 95 | * @description Returns the results of a SQL read query against the Tabeland network 96 | */ 97 | async queryByStatement( 98 | params: QueryParams<"objects" | undefined>, 99 | opts?: Signal 100 | ): Promise>; 101 | async queryByStatement( 102 | params: QueryParams<"table">, 103 | opts?: Signal 104 | ): Promise>; 105 | async queryByStatement( 106 | params: QueryParams, 107 | opts: Signal = {} 108 | ): Promise | ObjectsFormat> { 109 | return await getQuery(this.config, params as any, opts); 110 | } 111 | 112 | /** 113 | * Get transaction status 114 | * @description Returns the status of a given transaction receipt by hash 115 | */ 116 | async receiptByTransactionHash( 117 | params: ReceiptParams, 118 | opts: Signal = {} 119 | ): Promise { 120 | return await getTransactionReceipt(this.config, params, opts); 121 | } 122 | 123 | /** 124 | * Wait for transaction status 125 | * @description Polls for the status of a given transaction receipt by hash until 126 | */ 127 | async pollForReceiptByTransactionHash( 128 | params: ReceiptParams, 129 | opts: SignalAndInterval = {} 130 | ): Promise { 131 | return await pollTransactionReceipt(this.config, params, opts); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/helpers/ethers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | providers, 3 | type Signer, 4 | type Overrides, 5 | type ContractTransaction, 6 | type ContractReceipt, 7 | } from "ethers"; 8 | import { type TransactionReceipt } from "../validator/receipt.js"; 9 | import { type SignerConfig } from "./config.js"; 10 | 11 | type ExternalProvider = providers.ExternalProvider; 12 | const { getDefaultProvider, Web3Provider } = providers; 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-namespace 15 | declare module globalThis { 16 | // eslint-disable-next-line no-var 17 | var ethereum: ExternalProvider | undefined; 18 | } 19 | 20 | /** 21 | * Request a set of opinionated overrides to be used when calling the Tableland contract. 22 | * @param signer A valid web3 provider/signer. 23 | * @returns A promise that resolves to an object with overrides. 24 | */ 25 | export async function getOverrides({ 26 | signer, 27 | }: SignerConfig): Promise { 28 | // Hack: Revert to gasPrice to avoid always underpriced eip-1559 transactions on Polygon 29 | const opts: Overrides = {}; 30 | const network = await signer.provider?.getNetwork(); 31 | /* c8 ignore next 7 */ 32 | if (network?.chainId === 137) { 33 | const feeData = await signer.getFeeData(); 34 | if (feeData.gasPrice != null) { 35 | opts.gasPrice = 36 | Math.floor(feeData.gasPrice.toNumber() * 1.1) ?? undefined; 37 | } 38 | } 39 | return opts; 40 | } 41 | 42 | /** 43 | * RegistryReceipt is based on the TransactionReceipt type which defined by the API spec. 44 | * The API v1 has a known problem where it only returns the first tableId from a transaction. 45 | */ 46 | export type RegistryReceipt = Required< 47 | Omit 48 | >; 49 | 50 | /** 51 | * MultiEventTransactionReceipt represents a mapping of a response from a Validator 52 | * transaction receipt to the tableIds that were affected. 53 | * @typedef {Object} MultiEventTransactionReceipt 54 | * @property {string[]} tableIds - The list of table ids affected in the transaction 55 | * @property {string} transactionHash - The hash of the transaction 56 | * @property {number} blockNumber - The block number of the transaction 57 | * @property {number} chainId - The chain id of the transaction 58 | */ 59 | export interface MultiEventTransactionReceipt { 60 | tableIds: string[]; 61 | transactionHash: string; 62 | blockNumber: number; 63 | chainId: number; 64 | } 65 | 66 | /** 67 | * 68 | * Given a transaction, this helper will return the tableIds that were part of the transaction. 69 | * Especially useful for transactions that create new tables because you need the tableId to 70 | * calculate the full table name. 71 | * @param {tx} a contract transaction 72 | * @returns {MultiEventTransactionReceipt} tableland receipt 73 | * 74 | */ 75 | export async function getContractReceipt( 76 | tx: ContractTransaction 77 | ): Promise { 78 | const receipt = await tx.wait(); 79 | 80 | /* c8 ignore next */ 81 | const events = receipt.events ?? []; 82 | const transactionHash = receipt.transactionHash; 83 | const blockNumber = receipt.blockNumber; 84 | const chainId = tx.chainId; 85 | const tableIds: string[] = []; 86 | for (const event of events) { 87 | const tableId = 88 | event.args?.tableId != null && event.args.tableId.toString(); 89 | switch (event.event) { 90 | case "CreateTable": 91 | case "RunSQL": 92 | if (tableId != null) tableIds.push(tableId); 93 | 94 | break; 95 | default: 96 | // Could be a Transfer or other 97 | } 98 | } 99 | return { tableIds, transactionHash, blockNumber, chainId }; 100 | } 101 | 102 | /** 103 | * Request a signer object from the global ethereum object. 104 | * @param external A valid external provider. Defaults to `globalThis.ethereum` if not provided. 105 | * @returns A promise that resolves to a valid web3 provider/signer 106 | * @throws If no global ethereum object is available. 107 | */ 108 | export async function getSigner(external?: ExternalProvider): Promise { 109 | const provider = external ?? globalThis.ethereum; 110 | if (provider == null) { 111 | throw new Error("provider error: missing global ethereum provider"); 112 | } 113 | if (provider.request == null) { 114 | throw new Error( 115 | "provider error: missing request method on ethereum provider" 116 | ); 117 | } 118 | await provider.request({ method: "eth_requestAccounts" }); 119 | const web3Provider = new Web3Provider(provider); 120 | return web3Provider.getSigner(); 121 | } 122 | 123 | export { 124 | Signer, 125 | getDefaultProvider, 126 | type ExternalProvider, 127 | type ContractTransaction, 128 | type ContractReceipt, 129 | }; 130 | -------------------------------------------------------------------------------- /src/validator/client/types.ts: -------------------------------------------------------------------------------- 1 | export type Method = 2 | | "get" 3 | | "post" 4 | | "put" 5 | | "patch" 6 | | "delete" 7 | | "head" 8 | | "options"; 9 | 10 | export type OpenapiPaths = { 11 | [P in keyof Paths]: { 12 | [M in Method]?: unknown; 13 | }; 14 | }; 15 | 16 | export type OpArgType = OP extends { 17 | parameters?: { 18 | path?: infer P; 19 | query?: infer Q; 20 | body?: infer B; 21 | header?: unknown; // ignore 22 | cookie?: unknown; // ignore 23 | }; 24 | // openapi 3 25 | requestBody?: { 26 | content: { 27 | "application/json": infer RB; 28 | }; 29 | }; 30 | } 31 | ? P & Q & (B extends Record ? B[keyof B] : unknown) & RB 32 | : Record; 33 | 34 | type OpResponseTypes = OP extends { 35 | responses: infer R; 36 | } 37 | ? { 38 | [S in keyof R]: R[S] extends { schema?: infer S } // openapi 2 39 | ? S 40 | : R[S] extends { content: { "application/json": infer C } } // openapi 3 41 | ? C 42 | : S extends "default" 43 | ? R[S] 44 | : unknown; 45 | } 46 | : never; 47 | 48 | type _OpReturnType = 200 extends keyof T 49 | ? T[200] 50 | : 201 extends keyof T 51 | ? T[201] 52 | : "default" extends keyof T 53 | ? T["default"] 54 | : unknown; 55 | 56 | export type OpReturnType = _OpReturnType>; 57 | 58 | type _OpDefaultReturnType = "default" extends keyof T 59 | ? T["default"] 60 | : unknown; 61 | 62 | export type OpDefaultReturnType = _OpDefaultReturnType>; 63 | 64 | // private symbol to prevent narrowing on "default" error status 65 | const never: unique symbol = Symbol( 66 | "private symbol to prevent narrowing on 'default' error status" 67 | ); 68 | 69 | type _OpErrorType = { 70 | [S in Exclude]: { 71 | status: S extends "default" ? typeof never : S; 72 | data: T[S]; 73 | }; 74 | }[Exclude]; 75 | 76 | type Coalesce = [T] extends [never] ? D : T; 77 | 78 | // coalesce default error type 79 | export type OpErrorType = Coalesce< 80 | _OpErrorType>, 81 | { status: number; data: any } 82 | >; 83 | 84 | export type CustomRequestInit = Omit & { 85 | readonly headers: Headers; 86 | }; 87 | 88 | export type Fetch = ( 89 | url: string, 90 | init: CustomRequestInit 91 | ) => Promise; 92 | 93 | export type _TypedFetch = ( 94 | arg: OpArgType, 95 | init?: RequestInit 96 | ) => Promise>>; 97 | 98 | export type TypedFetch = _TypedFetch & { 99 | Error: new (error: ApiError) => ApiError & { 100 | getActualType: () => OpErrorType; 101 | }; 102 | }; 103 | 104 | export type FetchArgType = F extends TypedFetch 105 | ? OpArgType 106 | : never; 107 | 108 | export type FetchReturnType = F extends TypedFetch 109 | ? OpReturnType 110 | : never; 111 | 112 | export type FetchErrorType = F extends TypedFetch 113 | ? OpErrorType 114 | : never; 115 | 116 | type _CreateFetch = [Q] extends [never] 117 | ? () => TypedFetch 118 | : (query: Q) => TypedFetch; 119 | 120 | export type CreateFetch = M extends "post" | "put" | "patch" | "delete" 121 | ? OP extends { parameters: { query: infer Q } } 122 | ? _CreateFetch 123 | : _CreateFetch 124 | : _CreateFetch; 125 | 126 | export type Middleware = ( 127 | url: string, 128 | init: CustomRequestInit, 129 | next: Fetch 130 | ) => Promise; 131 | 132 | export interface FetchConfig { 133 | baseUrl?: string; 134 | init?: RequestInit; 135 | use?: Middleware[]; 136 | } 137 | 138 | export interface Request { 139 | baseUrl: string; 140 | method: Method; 141 | path: string; 142 | queryParams: string[]; // even if a post these will be sent in query 143 | payload: any; 144 | init?: RequestInit; 145 | fetch: Fetch; 146 | } 147 | 148 | export interface ApiResponse { 149 | readonly headers: Headers; 150 | readonly url: string; 151 | readonly ok: boolean; 152 | readonly status: number; 153 | readonly statusText: string; 154 | readonly data: R; 155 | } 156 | 157 | export class ApiError extends Error { 158 | readonly headers: Headers; 159 | readonly url: string; 160 | readonly status: number; 161 | readonly statusText: string; 162 | readonly data: any; 163 | 164 | constructor(response: Omit) { 165 | super(response.statusText); 166 | Object.setPrototypeOf(this, new.target.prototype); 167 | 168 | this.headers = response.headers; 169 | this.url = response.url; 170 | this.status = response.status; 171 | this.statusText = response.statusText; 172 | this.data = response.data; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/helpers/chains.ts: -------------------------------------------------------------------------------- 1 | import { 2 | proxies, 3 | baseURIs, 4 | type TablelandNetworkConfig, 5 | } from "@tableland/evm/network.js"; 6 | 7 | /** 8 | * The set of supported chain names as used by the Tableland network. 9 | */ 10 | export type ChainName = keyof TablelandNetworkConfig; 11 | 12 | /** 13 | * Chain information used to determine defaults for the set of supported chains. 14 | */ 15 | export interface ChainInfo { 16 | chainName: ChainName; 17 | chainId: number; 18 | contractAddress: string; 19 | baseUrl: string; 20 | [key: string]: ChainInfo[keyof ChainInfo]; 21 | } 22 | 23 | // We simply pull this automatically from @tableland/evm to avoid keeping track seperately here. 24 | const entries = Object.entries(proxies) as Array<[ChainName, string]>; 25 | const mapped = entries.map(([chainName, contractAddress]) => { 26 | const uri = new URL(baseURIs[chainName]); 27 | const baseUrl = `${uri.protocol}//${uri.host}/api/v1`; 28 | const chainId = parseInt( 29 | uri.pathname 30 | .split("/") 31 | .filter((v) => v !== "") 32 | .pop() /* c8 ignore next */ ?? "" 33 | ); 34 | const entry: [ChainName, any] = [ 35 | chainName, 36 | { chainName, chainId, contractAddress, baseUrl }, 37 | ]; 38 | return entry; 39 | }); 40 | 41 | /** 42 | * The set of chains and their information as supported by the Tableland network. 43 | */ 44 | export const supportedChains = Object.fromEntries(mapped) as Record< 45 | ChainName, 46 | ChainInfo 47 | >; 48 | 49 | // Not exported 50 | const supportedChainsById = Object.fromEntries( 51 | Object.values(supportedChains).map((v) => [v.chainId, v]) 52 | ); 53 | 54 | /** 55 | * Get the default chain information for a given chain name. 56 | * @param chainNameOrId The requested chain name. 57 | * @returns An object containing the default chainId, contractAddress, chainName, and baseUrl for the given chain. 58 | */ 59 | export function getChainInfo(chainNameOrId: ChainName | number): ChainInfo { 60 | const chainInfo = 61 | typeof chainNameOrId === "number" 62 | ? supportedChainsById[chainNameOrId] 63 | : supportedChains[chainNameOrId]; 64 | 65 | /* c8 ignore next 3 */ 66 | if (chainInfo == null) { 67 | throw new Error(`cannot use unsupported chain: ${chainNameOrId}`); 68 | } 69 | 70 | return chainInfo; 71 | } 72 | 73 | export function isTestnet(chainNameOrId: ChainName | number): boolean { 74 | const includesTestnet = 75 | getChainInfo(chainNameOrId).baseUrl.includes("testnet"); 76 | return ( 77 | includesTestnet || 78 | chainNameOrId === "localhost" || 79 | chainNameOrId === "local-tableland" || 80 | chainNameOrId === 31337 81 | ); 82 | } 83 | 84 | /** 85 | * Get the default contract address for a given chain name. 86 | * @param chainNameOrId The requested chain name. 87 | * @returns A hex string representing the default address for the Tableland registry contract. 88 | */ 89 | export function getContractAddress(chainNameOrId: ChainName | number): string { 90 | return getChainInfo(chainNameOrId).contractAddress; 91 | } 92 | 93 | /** 94 | * Get the default chain id for a given chain name. 95 | * @param chainNameOrId The requested chain name. 96 | * @returns A number representing the default chain id of the requested chain. 97 | */ 98 | export function getChainId(chainNameOrId: ChainName | number): number { 99 | return getChainInfo(chainNameOrId).chainId; 100 | } 101 | 102 | /** 103 | * Get the default host uri for a given chain name. 104 | * @param chainNameOrId The requested chain name. 105 | * @returns A string representing the default host uri for a given chain. 106 | */ 107 | export function getBaseUrl(chainNameOrId: ChainName | number): string { 108 | return getChainInfo(chainNameOrId).baseUrl; 109 | } 110 | 111 | /** 112 | * Override the internal list of registry addresses and validator urls that will be used for Contract calls and read queries 113 | * @param chainNameOrId Either the chain name or chainId. For a list of chain names see the evm-tableland networks file 114 | * @param values The values you would like to use to override the defaults. 115 | * Example: {contractAddress: "0x000deadbeef", baseUrl: "https://my.validator.mydomain.tld"} 116 | * @returns void 117 | */ 118 | // TODO: It seems important to add this to the docs somewhere since it's the key 119 | // to using the SDK for the non-default Validator 120 | export function overrideDefaults( 121 | chainNameOrId: ChainName | number, 122 | values: Record 123 | ): void { 124 | if (values == null || typeof values !== "object") { 125 | throw new Error("override values must be an Object"); 126 | } 127 | for (const [key, value] of Object.entries(values)) { 128 | if (typeof chainNameOrId === "number") { 129 | const found = getChainInfo(chainNameOrId); 130 | found[key] = value; 131 | supportedChains[found.chainName][key as keyof ChainInfo] = value; 132 | } else { 133 | const found = getChainInfo(chainNameOrId); 134 | found[key] = value; 135 | supportedChainsById[found.chainId][key as keyof ChainInfo] = value; 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/validator/client/README.md: -------------------------------------------------------------------------------- 1 | > This is a local copy of openapi-typescript-fetch 2 | 3 | # 📘️ openapi-typescript-fetch 4 | 5 | A typed fetch client for [openapi-typescript](https://github.com/drwpow/openapi-typescript) 6 | 7 | **Features** 8 | 9 | Supports JSON request and responses 10 | 11 | - ✅ [OpenAPI 3.0](https://swagger.io/specification) 12 | - ✅ [Swagger 2.0](https://swagger.io/specification/v2/) 13 | 14 | ### Usage 15 | 16 | **Generate typescript definition from schema** 17 | 18 | ```bash 19 | npx openapi-typescript https://petstore.swagger.io/v2/swagger.json --output petstore.ts 20 | 21 | # 🔭 Loading spec from https://petstore.swagger.io/v2/swagger.json… 22 | # 🚀 https://petstore.swagger.io/v2/swagger.json -> petstore.ts [650ms] 23 | ``` 24 | 25 | **Typed fetch client** 26 | 27 | ```ts 28 | import 'whatwg-fetch' 29 | 30 | import { Fetcher } from 'openapi-typescript-fetch' 31 | 32 | import { paths } from './petstore' 33 | 34 | // declare fetcher for paths 35 | const fetcher = Fetcher.for() 36 | 37 | // global configuration 38 | fetcher.configure({ 39 | baseUrl: 'https://petstore.swagger.io/v2', 40 | init: { 41 | headers: { 42 | ... 43 | }, 44 | }, 45 | use: [...] // middlewares 46 | }) 47 | 48 | // create fetch operations 49 | const findPetsByStatus = fetcher.path('/pet/findByStatus').method('get').create() 50 | const addPet = fetcher.path('/pet').method('post').create() 51 | 52 | // fetch 53 | const { status, data: pets } = await findPetsByStatus({ 54 | status: ['available', 'pending'], 55 | }) 56 | 57 | console.log(pets[0]) 58 | ``` 59 | 60 | ### Typed Error Handling 61 | 62 | A non-ok fetch response throws a generic `ApiError` 63 | 64 | But an Openapi document can declare a different response type for each status code, or a default error response type 65 | 66 | These can be accessed via a `discriminated union` on status, as in code snippet below 67 | 68 | ```ts 69 | const findPetsByStatus = fetcher.path('/pet/findByStatus').method('get').create() 70 | const addPet = fetcher.path('/pet').method('post').create() 71 | 72 | try { 73 | await findPetsByStatus({ ... }) 74 | await addPet({ ... }) 75 | } catch(e) { 76 | // check which operation threw the exception 77 | if (e instanceof addPet.Error) { 78 | // get discriminated union { status, data } 79 | const error = e.getActualType() 80 | if (error.status === 400) { 81 | error.data.validationErrors // only available for a 400 response 82 | } else if (error.status === 500) { 83 | error.data.errorMessage // only available for a 500 response 84 | } else { 85 | ... 86 | } 87 | } 88 | } 89 | ``` 90 | 91 | ### Middleware 92 | 93 | Middlewares can be used to pre and post process fetch operations (log api calls, add auth headers etc) 94 | 95 | ```ts 96 | 97 | import { Middleware } from 'openapi-typescript-fetch' 98 | 99 | const logger: Middleware = async (url, init, next) => { 100 | console.log(`fetching ${url}`) 101 | const response = await next(url, init) 102 | console.log(`fetched ${url}`) 103 | return response 104 | } 105 | 106 | fetcher.configure({ 107 | baseUrl: 'https://petstore.swagger.io/v2', 108 | init: { ... }, 109 | use: [logger], 110 | }) 111 | 112 | // or 113 | 114 | fetcher.use(logger) 115 | ``` 116 | 117 | ### Server Side Usage 118 | 119 | This library can be used server side with [node-fetch](https://www.npmjs.com/package/node-fetch) 120 | 121 | Node CommonJS setup 122 | 123 | ```ts 124 | // install node-fetch v2 125 | npm install node-fetch@2 126 | npm install @types/node-fetch@2 127 | 128 | // fetch-polyfill.ts 129 | import fetch, { Headers, Request, Response } from 'node-fetch' 130 | 131 | if (!globalThis.fetch) { 132 | globalThis.fetch = fetch as any 133 | globalThis.Headers = Headers as any 134 | globalThis.Request = Request as any 135 | globalThis.Response = Response as any 136 | } 137 | 138 | // index.ts 139 | import './fetch-polyfill' 140 | ``` 141 | 142 | ### Utility Types 143 | 144 | - `OpArgType` - Infer argument type of an operation 145 | - `OpReturnType` - Infer return type of an operation 146 | - `OpErrorType` - Infer error type of an operation 147 | - `FetchArgType` - Argument type of a typed fetch operation 148 | - `FetchReturnType` - Return type of a typed fetch operation 149 | - `FetchErrorType` - Error type of a typed fetch operation 150 | - `TypedFetch` - Fetch operation type 151 | 152 | ```ts 153 | import { paths, operations } from "./petstore"; 154 | 155 | type Arg = OpArgType; 156 | type Ret = OpReturnType; 157 | type Err = OpErrorType; 158 | 159 | type Arg = OpArgType; 160 | type Ret = OpReturnType; 161 | type Err = OpErrorType; 162 | 163 | type FindPetsByStatus = TypedFetch; 164 | 165 | const findPetsByStatus = fetcher 166 | .path("/pet/findByStatus") 167 | .method("get") 168 | .create(); 169 | 170 | type Arg = FetchArgType; 171 | type Ret = FetchReturnType; 172 | type Err = FetchErrorType; 173 | ``` 174 | 175 | ### Utility Methods 176 | 177 | - `arrayRequestBody` - Helper to merge params when request body is an array [see issue](https://github.com/ajaishankar/openapi-typescript-fetch/issues/3#issuecomment-952963986) 178 | 179 | ```ts 180 | const body = arrayRequestBody([{ item: 1 }], { param: 2 }); 181 | 182 | // body type is { item: number }[] & { param: number } 183 | ``` 184 | 185 | Happy fetching! 👍 186 | -------------------------------------------------------------------------------- /src/helpers/binding.ts: -------------------------------------------------------------------------------- 1 | // Regexp to extract any placeholder types (?, ?NNN, @AAA, $AAA, or :AAA ) that are 2 | // _not_ within quotes (", ', `) or [] "escapes". This works by having two top level 3 | // "groups" that are or'd together. The first group is non-capturing, and catches quotes 4 | // and escapes, and the second group is capturing, and catches all the placeholder types 5 | export const placeholderRegExp = 6 | /(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|\[(?:[^[\\]|\\.)*\])|(\?\d*|[:@$][a-zA-Z_]\w+)/gmu; 7 | 8 | function isPlainObject(obj: any): obj is Record { 9 | if (typeof obj !== "object" || obj === null) return false; 10 | const proto = Object.getPrototypeOf(obj); 11 | return proto !== null && Object.getPrototypeOf(proto) === null; 12 | } 13 | 14 | function bindString(param: string, quoteEscaper = "''"): string { 15 | return "'" + param.replace(/'/g, quoteEscaper) + "'"; 16 | } 17 | 18 | function bindBoolean(param: boolean): string { 19 | return Number(param).toString(); 20 | } 21 | 22 | function bindNumber(param: number | bigint): string { 23 | return param.toString(); 24 | } 25 | 26 | function bindDate(param: Date): string { 27 | return param.valueOf().toString(); 28 | } 29 | 30 | function bindBytes(param: Uint8Array): string { 31 | const hex = param.reduce((t, x) => t + x.toString(16).padStart(2, "0"), ""); 32 | return `X'${hex}'`; 33 | } 34 | 35 | function bindObject(param: any): string { 36 | return bindString(JSON.stringify(param)); 37 | } 38 | 39 | function bindNull(_param: undefined | null): string { 40 | return "NULL"; 41 | } 42 | 43 | function bindToString(param: any): string { 44 | return bindString(String(param)); 45 | } 46 | 47 | interface SQL { 48 | toSQL: () => string; 49 | } 50 | 51 | function isSQL(param: any): param is SQL { 52 | return typeof param.toSQL === "function"; 53 | } 54 | 55 | function bindToSQL(param: SQL): string { 56 | return param.toSQL(); 57 | } 58 | 59 | function bindValue(param: BaseType): string { 60 | switch (typeof param) { 61 | case "bigint": 62 | case "number": 63 | return bindNumber(param); 64 | case "boolean": 65 | return bindBoolean(param); 66 | case "string": 67 | return bindString(param); 68 | case "undefined": 69 | return bindNull(param); 70 | case "object": 71 | if (param instanceof Date) { 72 | return bindDate(param); 73 | } else if (param instanceof Uint8Array) { 74 | return bindBytes(param); 75 | } else if (param == null) { 76 | return bindNull(param); 77 | } else if (isPlainObject(param)) { 78 | return bindObject(param); 79 | } else if (isSQL(param)) { 80 | return bindToSQL(param); 81 | /* c8 ignore next 3 */ 82 | } else { 83 | return bindToString(param); 84 | } 85 | default: 86 | return bindToString(param); 87 | } 88 | } 89 | 90 | export type BaseType = 91 | | string // strings are left as is (and escaped) 92 | | boolean // boolean is converted to ints 93 | | number // numbers are left as is 94 | | bigint // bigints are converted to ints 95 | | Uint8Array // bytes arrays are converted to byte strings 96 | | null // null is converted to NULL 97 | | undefined // undefined is converted to NULL 98 | | Date // Date objects are converted to their valueOf 99 | | SQL // Anything that has a toSQL method 100 | | Record; // JSON objects 101 | 102 | export type ValuesType = BaseType | BaseType[] | Record; 103 | 104 | export interface Parameters { 105 | anon: BaseType[]; 106 | named?: Record; 107 | } 108 | 109 | export function getParameters(...values: ValuesType[]): Parameters { 110 | const initialValue: Required = { anon: [], named: {} }; 111 | const flat = values.flat(Infinity); 112 | const result = flat.reduce( 113 | ({ anon, named }: Required, v: any) => { 114 | if (isPlainObject(v)) { 115 | return { anon, named: { ...named, ...v } }; 116 | } else { 117 | return { anon: [...anon, v], named }; 118 | } 119 | }, 120 | initialValue 121 | ); 122 | return result; 123 | } 124 | 125 | export function bindValues(sql: string, parameters?: Parameters): string { 126 | // https://sqlite.org/forum/forumpost/4350e973ad 127 | if (parameters == null) { 128 | return sql; 129 | } 130 | const { anon, named } = parameters; 131 | let bindIndex = 0; 132 | const seen = new Set(); 133 | const a = anon; 134 | const n = named ?? {}; 135 | const replaced = sql.replace( 136 | placeholderRegExp, 137 | function (m: string, group: string) { 138 | if (group == null) { 139 | return m; 140 | } 141 | if (group === "?") { 142 | return bindValue(a[bindIndex++]); 143 | } else if (/\?\d*/.test(group)) { 144 | const index = parseInt(group.slice(1)) - 1; 145 | if (index >= bindIndex) { 146 | bindIndex = index + 1; 147 | } 148 | return bindValue(a[index]); 149 | } else if (/[:@$][a-zA-Z_]\w+/g.test(group)) { 150 | const key = group.slice(1); 151 | seen.add(key); 152 | return bindValue(n[key]); 153 | /* c8 ignore next 3 */ 154 | } else { 155 | return m; 156 | } 157 | } 158 | ); 159 | const expectedParams = bindIndex + seen.size; 160 | const receivedParams = a.length + Object.keys(n).length; 161 | if (expectedParams !== receivedParams) { 162 | throw new Error( 163 | `parameter mismatch: received (${receivedParams}), expected ${expectedParams}` 164 | ); 165 | } 166 | return replaced; 167 | } 168 | -------------------------------------------------------------------------------- /test/browser/test/happy.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test.describe("browser tests", function () { 4 | let tableName; 5 | 6 | test("has title", async function ({ page }) { 7 | await page.goto("http://localhost:5173/"); 8 | 9 | // Expect a title "to contain" a substring. 10 | await expect(page).toHaveTitle(/Tableland SDK Test App/); 11 | }); 12 | 13 | test("can create", async function ({ page }) { 14 | await page.goto("http://localhost:5173/"); 15 | 16 | const success = "table was created"; 17 | await page.type("input[name=statement]", "CREATE TABLE browser_table (k text, v text, num integer);"); 18 | await page.type("input[name=success]", success); 19 | await page.click("input[name=submit-sql]"); 20 | 21 | await expect(page.getByTestId("status")).toHaveText(success); 22 | 23 | const responseStr = await page.getByTestId("response").textContent(); 24 | const responseObj = JSON.parse(responseStr); 25 | 26 | tableName = responseObj.meta.txn.name; 27 | 28 | expect(tableName).toMatch(/browser_table/); 29 | }); 30 | 31 | test("can insert", async function ({ page }) { 32 | await page.goto("http://localhost:5173/"); 33 | 34 | const success = "data was inserted"; 35 | // TODO: potentially convert to typescript, but until then we need to ignore ts specific linting rules 36 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 37 | await page.type("input[name=statement]", `INSERT INTO ${tableName} (k, v, num) VALUES ('name', 'number', 1);`); 38 | await page.type("input[name=success]", success); 39 | await page.click("input[name=submit-sql]"); 40 | 41 | await expect(page.getByTestId("status")).toHaveText(success); 42 | }); 43 | 44 | test("can update", async function ({ page }) { 45 | await page.goto("http://localhost:5173/"); 46 | 47 | const success = "table was updated"; 48 | // TODO: potentially convert to typescript, but until then we need to ignore ts specific linting rules 49 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 50 | await page.type("input[name=statement]", `UPDATE ${tableName} SET num = 2 WHERE num = 1;`); 51 | await page.type("input[name=success]", success); 52 | await page.click("input[name=submit-sql]"); 53 | 54 | await expect(page.getByTestId("status")).toHaveText(success); 55 | }); 56 | 57 | test("can read", async function ({ page }) { 58 | await page.goto("http://localhost:5173/"); 59 | 60 | const success = "table was read"; 61 | // TODO: potentially convert to typescript, but until then we need to ignore ts specific linting rules 62 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 63 | await page.type("input[name=statement]", `SELECT * FROM ${tableName};`); 64 | await page.type("input[name=success]", success); 65 | await page.click("input[name=submit-sql]"); 66 | 67 | await expect(page.getByTestId("status")).toHaveText(success); 68 | 69 | const responseStr = await page.getByTestId("response").textContent(); 70 | const responseObj = JSON.parse(responseStr); 71 | 72 | const results = responseObj.results 73 | 74 | expect(results.length).toEqual(1); 75 | expect(results[0].k).toEqual("name"); 76 | expect(results[0].v).toEqual("number"); 77 | expect(results[0].num).toEqual(2); 78 | }); 79 | 80 | test("can delete", async function ({ page }) { 81 | await page.goto("http://localhost:5173/"); 82 | 83 | const success = "data was deleted"; 84 | // TODO: potentially convert to typescript, but until then we need to ignore ts specific linting rules 85 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 86 | await page.type("input[name=statement]", `DELETE FROM ${tableName} WHERE num = 2;`); 87 | await page.type("input[name=success]", success); 88 | await page.click("input[name=submit-sql]"); 89 | 90 | await expect(page.getByTestId("status")).toHaveText(success); 91 | }); 92 | 93 | test("can get validator health", async function ({ page }) { 94 | await page.goto("http://localhost:5173/"); 95 | 96 | await page.click("input[name=health]"); 97 | 98 | await expect(page.getByTestId("status")).toHaveText("validator health is good"); 99 | }); 100 | 101 | test("can get table by id", async function ({ page }) { 102 | await page.goto("http://localhost:5173/"); 103 | 104 | await page.type("input[name=tableid]", `1`); 105 | await page.click("input[name=get-table]"); 106 | await expect(page.getByTestId("status")).toHaveText("table name: healthbot_31337_1"); 107 | 108 | const schemaStr = await page.getByTestId("response").textContent(); 109 | const schema = JSON.parse(schemaStr); 110 | 111 | expect(schema.columns.length).toEqual(1); 112 | expect(schema.columns[0].name).toEqual("counter"); 113 | expect(schema.columns[0].type).toEqual("integer"); 114 | }); 115 | 116 | test("can get my tables", async function ({ page }) { 117 | await page.goto("http://localhost:5173/"); 118 | 119 | // create a table for this test, so we don't rely on other test's tables 120 | const success = "table was created"; 121 | await page.type("input[name=statement]", "CREATE TABLE regi_test (k text, v text);"); 122 | await page.type("input[name=success]", success); 123 | await page.click("input[name=submit-sql]"); 124 | 125 | await expect(page.getByTestId("status")).toHaveText(success); 126 | 127 | // now that the table exists, test that the registry can get the table 128 | await page.click("input[name=get-my-tables]"); 129 | await expect(page.getByTestId("status")).toHaveText("got my tables"); 130 | 131 | const tablesStr = await page.getByTestId("response").textContent(); 132 | const tables = JSON.parse(tablesStr); 133 | 134 | expect(tables.length).toBeGreaterThan(0); 135 | expect(tables[tables.length - 1].chainId).toEqual(31337); 136 | // test that the last table created has a tableId that is a string of a number 137 | expect(isNaN(Number(tables[tables.length - 1].tableId))).toEqual(false); 138 | }); 139 | 140 | }); 141 | -------------------------------------------------------------------------------- /.github/workflows/combine-prs.yml: -------------------------------------------------------------------------------- 1 | name: "Combine PRs" 2 | 3 | # Controls when the action will run - in this case triggered manually 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | branchPrefix: 8 | description: "Branch prefix to find combinable PRs based on" 9 | required: true 10 | default: "dependabot" 11 | mustBeGreen: 12 | description: "Only combine PRs that are green (status is success)" 13 | required: true 14 | type: boolean 15 | default: true 16 | combineBranchName: 17 | description: "Name of the branch to combine PRs into" 18 | required: true 19 | default: "combine-prs-branch" 20 | ignoreLabel: 21 | description: "Exclude PRs with this label" 22 | required: true 23 | default: "nocombine" 24 | 25 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 26 | jobs: 27 | # This workflow contains a single job called "combine-prs" 28 | combine-prs: 29 | # The type of runner that the job will run on 30 | runs-on: ubuntu-latest 31 | 32 | # Steps represent a sequence of tasks that will be executed as part of the job 33 | steps: 34 | - uses: actions/github-script@v6 35 | id: create-combined-pr 36 | name: Create Combined PR 37 | with: 38 | github-token: ${{secrets.TEXTILEIO_MACHINE_ACCESS_TOKEN}} 39 | script: | 40 | const pulls = await github.paginate('GET /repos/:owner/:repo/pulls', { 41 | owner: context.repo.owner, 42 | repo: context.repo.repo 43 | }); 44 | let branchesAndPRStrings = []; 45 | let baseBranch = null; 46 | let baseBranchSHA = null; 47 | for (const pull of pulls) { 48 | const branch = pull['head']['ref']; 49 | console.log('Pull for branch: ' + branch); 50 | if (branch.startsWith('${{ github.event.inputs.branchPrefix }}')) { 51 | console.log('Branch matched prefix: ' + branch); 52 | let statusOK = true; 53 | if(${{ github.event.inputs.mustBeGreen }}) { 54 | console.log('Checking green status: ' + branch); 55 | 56 | const checkRuns = await github.request('GET /repos/{owner}/{repo}/commits/{ref}/check-runs', { 57 | owner: context.repo.owner, 58 | repo: context.repo.repo, 59 | ref: branch 60 | }); 61 | for await (const cr of checkRuns.data.check_runs) { 62 | console.log('Validating check conclusion: ' + cr.conclusion); 63 | if(cr.conclusion != 'success') { 64 | console.log('Discarding ' + branch + ' with check conclusion ' + cr.conclusion); 65 | statusOK = false; 66 | } 67 | } 68 | } 69 | console.log('Checking labels: ' + branch); 70 | const labels = pull['labels']; 71 | for(const label of labels) { 72 | const labelName = label['name']; 73 | console.log('Checking label: ' + labelName); 74 | if(labelName == '${{ github.event.inputs.ignoreLabel }}') { 75 | console.log('Discarding ' + branch + ' with label ' + labelName); 76 | statusOK = false; 77 | } 78 | } 79 | if (statusOK) { 80 | console.log('Adding branch to array: ' + branch); 81 | const prString = '#' + pull['number'] + ' ' + pull['title']; 82 | branchesAndPRStrings.push({ branch, prString }); 83 | baseBranch = pull['base']['ref']; 84 | baseBranchSHA = pull['base']['sha']; 85 | } 86 | } 87 | } 88 | if (branchesAndPRStrings.length == 0) { 89 | core.setFailed('No PRs/branches matched criteria'); 90 | return; 91 | } 92 | try { 93 | await github.rest.git.createRef({ 94 | owner: context.repo.owner, 95 | repo: context.repo.repo, 96 | ref: 'refs/heads/' + '${{ github.event.inputs.combineBranchName }}', 97 | sha: baseBranchSHA 98 | }); 99 | } catch (error) { 100 | console.log(error); 101 | core.setFailed('Failed to create combined branch - maybe a branch by that name already exists?'); 102 | return; 103 | } 104 | 105 | let combinedPRs = []; 106 | let mergeFailedPRs = []; 107 | for(const { branch, prString } of branchesAndPRStrings) { 108 | try { 109 | await github.rest.repos.merge({ 110 | owner: context.repo.owner, 111 | repo: context.repo.repo, 112 | base: '${{ github.event.inputs.combineBranchName }}', 113 | head: branch, 114 | }); 115 | console.log('Merged branch ' + branch); 116 | combinedPRs.push(prString); 117 | } catch (error) { 118 | console.log('Failed to merge branch ' + branch); 119 | mergeFailedPRs.push(prString); 120 | } 121 | } 122 | 123 | console.log('Creating combined PR'); 124 | const combinedPRsString = combinedPRs.join('\n'); 125 | let body = '✅ This PR was created by the Combine PRs action by combining the following PRs:\n' + combinedPRsString; 126 | if(mergeFailedPRs.length > 0) { 127 | const mergeFailedPRsString = mergeFailedPRs.join('\n'); 128 | body += '\n\n⚠️ The following PRs were left out due to merge conflicts:\n' + mergeFailedPRsString 129 | } 130 | await github.rest.pulls.create({ 131 | owner: context.repo.owner, 132 | repo: context.repo.repo, 133 | title: 'Combined PR', 134 | head: '${{ github.event.inputs.combineBranchName }}', 135 | base: baseBranch, 136 | body: body 137 | }); 138 | -------------------------------------------------------------------------------- /src/registry/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type SignerConfig, 3 | type Signer, 4 | type ContractTransaction, 5 | } from "../helpers/index.js"; 6 | import { type TableIdentifier } from "./contract.js"; 7 | import { listTables } from "./tables.js"; 8 | import { safeTransferFrom, type TransferParams } from "./transfer.js"; 9 | import { 10 | setController, 11 | type SetParams, 12 | getController, 13 | lockController, 14 | } from "./controller.js"; 15 | import { 16 | create, 17 | type CreateOneParams, 18 | type CreateManyParams, 19 | type CreateParams, 20 | } from "./create.js"; 21 | import { 22 | mutate, 23 | type Runnable, 24 | type RunSQLParams, 25 | type MutateManyParams, 26 | type MutateOneParams, 27 | type MutateParams, 28 | } from "./run.js"; 29 | 30 | export { 31 | type Result, 32 | type Metadata, 33 | type WaitableTransactionReceipt, 34 | type Named, 35 | } from "./utils.js"; 36 | 37 | export { 38 | type TableIdentifier, 39 | type CreateOneParams as CreateTableParams, // deprecated 40 | type CreateOneParams, 41 | type CreateManyParams, 42 | type CreateParams, 43 | type MutateOneParams, 44 | type MutateManyParams, 45 | type MutateParams, 46 | type RunSQLParams, // deprecated 47 | type Runnable, 48 | type TransferParams, 49 | type SetParams, 50 | }; 51 | 52 | /** 53 | * Registry provides direct access to remote Registry smart contract APIs. 54 | */ 55 | export class Registry { 56 | readonly config: SignerConfig; 57 | /** 58 | * Create a Registry instance with the specified connection configuration. 59 | * @param config The connection configuration. This must include an ethersjs 60 | * Signer. If passing the config from a pre-existing Database instance, it 61 | * must have a non-null signer key defined. 62 | */ 63 | constructor(config: Partial = {}) { 64 | /* c8 ignore next 3 */ 65 | if (config.signer == null) { 66 | throw new Error("missing signer information"); 67 | } 68 | this.config = config as SignerConfig; 69 | } 70 | 71 | /** 72 | * Create a Registry that is connected to the given Signer. 73 | * @param signer An ethersjs Signer to use for mutating queries. 74 | */ 75 | static async forSigner(signer: Signer): Promise { 76 | return new Registry({ signer }); 77 | } 78 | 79 | /** 80 | * Gets the list of table IDs of the requested owner. 81 | * @param owner The address owning the table. 82 | */ 83 | async listTables(owner?: string): Promise { 84 | return await listTables(this.config, owner); 85 | } 86 | 87 | /** 88 | * Safely transfers the ownership of a given table ID to another address. 89 | * 90 | * Requires the msg sender to be the owner, approved, or operator 91 | */ 92 | async safeTransferFrom(params: TransferParams): Promise { 93 | return await safeTransferFrom(this.config, params); 94 | } 95 | 96 | /** 97 | * Sets the controller for a table. Controller can be an EOA or contract address. 98 | * 99 | * When a table is created, it's controller is set to the zero address, which means that the 100 | * contract will not enforce write access control. In this situation, validators will not accept 101 | * transactions from non-owners unless explicitly granted access with "GRANT" SQL statements. 102 | * 103 | * When a controller address is set for a table, validators assume write access control is 104 | * handled at the contract level, and will accept all transactions. 105 | * 106 | * You can unset a controller address for a table by setting it back to the zero address. 107 | * This will cause validators to revert back to honoring owner and GRANT bases write access control. 108 | * 109 | * caller - the address that is setting the controller 110 | * tableId - the id of the target table 111 | * controller - the address of the controller (EOA or contract) 112 | * 113 | * Requirements: 114 | * 115 | * - contract must be unpaused 116 | * - `msg.sender` must be `caller` or contract owner and owner of `tableId` 117 | * - `tableId` must exist 118 | * - `tableId` controller must not be locked 119 | */ 120 | async setController(params: SetParams): Promise { 121 | return await setController(this.config, params); 122 | } 123 | 124 | /** 125 | * Locks the controller for a table _forever_. Controller can be an EOA or contract address. 126 | * 127 | * Although not very useful, it is possible to lock a table controller that is set to the zero address. 128 | * 129 | * caller - the address that is locking the controller 130 | * tableId - the id of the target table 131 | * 132 | * Requirements: 133 | * 134 | * - contract must be unpaused 135 | * - `msg.sender` must be `caller` or contract owner and owner of `tableId` 136 | * - `tableId` must exist 137 | * - `tableId` controller must not be locked 138 | */ 139 | async lockController( 140 | table: string | TableIdentifier 141 | ): Promise { 142 | return await lockController(this.config, table); 143 | } 144 | 145 | /** 146 | * Returns the controller for a table. 147 | * 148 | * tableId - the id of the target table 149 | */ 150 | async getController(table: string | TableIdentifier): Promise { 151 | return await getController(this.config, table); 152 | } 153 | 154 | /** 155 | * Creates a new table owned by `owner` using `statement` and returns its `tableId`. 156 | * 157 | * owner - the to-be owner of the new table 158 | * statement - the SQL statement used to create the table 159 | * 160 | * Requirements: 161 | * 162 | * - contract must be unpaused 163 | */ 164 | async create(params: CreateParams): Promise { 165 | return await create(this.config, params); 166 | } 167 | 168 | /** 169 | * @custom:deprecated Use `create` instead. 170 | */ 171 | async createTable(params: CreateOneParams): Promise { 172 | return await create(this.config, params); 173 | } 174 | 175 | /** 176 | * Runs a SQL statement for `caller` using `statement`. 177 | * 178 | * caller - the address that is running the SQL statement 179 | * tableId - the id of the target table 180 | * statement - the SQL statement to run 181 | * 182 | * Requirements: 183 | * 184 | * - contract must be unpaused 185 | * - `msg.sender` must be `caller` 186 | * - `tableId` must exist 187 | * - `caller` must be authorized by the table controller 188 | * - `statement` must be less than 35000 bytes after normalizing 189 | */ 190 | async mutate(params: MutateParams): Promise { 191 | return await mutate(this.config, params); 192 | } 193 | 194 | /** 195 | * Runs a set of SQL statements for `caller` using `runnables`. 196 | * @custom:deprecated Using this with a single statement is deprecated. Use `mutate` instead. 197 | */ 198 | async runSQL(params: MutateParams): Promise { 199 | return await mutate(this.config, params); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/validator/client/fetcher.ts: -------------------------------------------------------------------------------- 1 | /* c8-ignore */ 2 | /* eslint-disable @typescript-eslint/consistent-type-assertions */ 3 | /* eslint-disable @typescript-eslint/no-dynamic-delete */ 4 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 5 | import { 6 | ApiError, 7 | ApiResponse, 8 | CreateFetch, 9 | CustomRequestInit, 10 | Fetch, 11 | FetchConfig, 12 | Method, 13 | Middleware, 14 | OpArgType, 15 | OpenapiPaths, 16 | OpErrorType, 17 | Request, 18 | _TypedFetch, 19 | TypedFetch, 20 | } from "./types.js"; 21 | 22 | const sendBody = (method: Method) => 23 | method === "post" || 24 | method === "put" || 25 | method === "patch" || 26 | method === "delete"; 27 | 28 | function queryString(params: Record): string { 29 | const qs: string[] = []; 30 | 31 | const encode = (key: string, value: unknown) => 32 | `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`; 33 | 34 | Object.keys(params).forEach((key) => { 35 | const value = params[key]; 36 | if (value != null) { 37 | if (Array.isArray(value)) { 38 | value.forEach((value) => qs.push(encode(key, value))); 39 | } else { 40 | qs.push(encode(key, value)); 41 | } 42 | } 43 | }); 44 | 45 | if (qs.length > 0) { 46 | return `?${qs.join("&")}`; 47 | } 48 | 49 | return ""; 50 | } 51 | 52 | function getPath(path: string, payload: Record) { 53 | return path.replace(/\{([^}]+)\}/g, (_, key) => { 54 | const value = encodeURIComponent(payload[key]); 55 | delete payload[key]; 56 | return value; 57 | }); 58 | } 59 | 60 | function getQuery( 61 | method: Method, 62 | payload: Record, 63 | query: string[] 64 | ) { 65 | let queryObj = {} as any; 66 | 67 | if (sendBody(method)) { 68 | query.forEach((key) => { 69 | queryObj[key] = payload[key]; 70 | delete payload[key]; 71 | }); 72 | } else { 73 | queryObj = { ...payload }; 74 | } 75 | 76 | return queryString(queryObj); 77 | } 78 | 79 | function getHeaders(body?: string, init?: HeadersInit) { 80 | const headers = new Headers(init); 81 | 82 | if (body !== undefined && !headers.has("Content-Type")) { 83 | headers.append("Content-Type", "application/json"); 84 | } 85 | 86 | if (!headers.has("Accept")) { 87 | headers.append("Accept", "application/json"); 88 | } 89 | 90 | return headers; 91 | } 92 | 93 | function getBody(method: Method, payload: any) { 94 | const body = sendBody(method) ? JSON.stringify(payload) : undefined; 95 | // if delete don't send body if empty 96 | return method === "delete" && body === "{}" ? undefined : body; 97 | } 98 | 99 | function mergeRequestInit( 100 | first?: RequestInit, 101 | second?: RequestInit 102 | ): RequestInit { 103 | const headers = new Headers(first?.headers); 104 | const other = new Headers(second?.headers); 105 | 106 | for (const key of Object.keys(other)) { 107 | const value = other.get(key); 108 | if (value != null) { 109 | headers.set(key, value); 110 | } 111 | } 112 | return { ...first, ...second, headers }; 113 | } 114 | 115 | function getFetchParams(request: Request) { 116 | // clone payload 117 | // if body is a top level array [ 'a', 'b', param: value ] with param values 118 | // using spread [ ...payload ] returns [ 'a', 'b' ] and skips custom keys 119 | // cloning with Object.assign() preserves all keys 120 | const payload = Object.assign( 121 | Array.isArray(request.payload) ? [] : {}, 122 | request.payload 123 | ); 124 | 125 | const path = getPath(request.path, payload); 126 | const query = getQuery(request.method, payload, request.queryParams); 127 | const body = getBody(request.method, payload); 128 | const headers = getHeaders(body, request.init?.headers); 129 | const url = request.baseUrl + path + query; 130 | 131 | const init = { 132 | ...request.init, 133 | method: request.method.toUpperCase(), 134 | headers, 135 | body, 136 | }; 137 | 138 | return { url, init }; 139 | } 140 | 141 | async function getResponseData(response: Response) { 142 | const contentType = response.headers.get("content-type"); 143 | if (response.status === 204 /* no content */) { 144 | return undefined; 145 | } 146 | if (contentType?.includes("application/json") ?? false) { 147 | return await response.json(); 148 | } 149 | const text = await response.text(); 150 | try { 151 | return JSON.parse(text); 152 | } catch (e) { 153 | return text; 154 | } 155 | } 156 | 157 | async function fetchJson(url: string, init: RequestInit): Promise { 158 | const response = await fetch(url, init); 159 | 160 | const data = await getResponseData(response); 161 | 162 | const result = { 163 | headers: response.headers, 164 | url: response.url, 165 | ok: response.ok, 166 | status: response.status, 167 | statusText: response.statusText, 168 | data, 169 | }; 170 | 171 | if (result.ok) { 172 | return result; 173 | } 174 | 175 | throw new ApiError(result); 176 | } 177 | 178 | function wrapMiddlewares(middlewares: Middleware[], fetch: Fetch): Fetch { 179 | type Handler = ( 180 | index: number, 181 | url: string, 182 | init: CustomRequestInit 183 | ) => Promise; 184 | 185 | const handler: Handler = async (index, url, init) => { 186 | if (middlewares == null || index === middlewares.length) { 187 | return await fetch(url, init); 188 | } 189 | const current = middlewares[index]; 190 | return await current( 191 | url, 192 | init, 193 | async (nextUrl, nextInit) => await handler(index + 1, nextUrl, nextInit) 194 | ); 195 | }; 196 | 197 | return async (url, init) => await handler(0, url, init); 198 | } 199 | 200 | async function fetchUrl(request: Request) { 201 | const { url, init } = getFetchParams(request); 202 | 203 | const response = await request.fetch(url, init); 204 | 205 | return response as ApiResponse; 206 | } 207 | 208 | function createFetch(fetch: _TypedFetch): TypedFetch { 209 | const fun = async (payload: OpArgType, init?: RequestInit) => { 210 | try { 211 | return await fetch(payload, init); 212 | } catch (err) { 213 | if (err instanceof ApiError) { 214 | throw new fun.Error(err); 215 | } 216 | throw err; 217 | } 218 | }; 219 | 220 | fun.Error = class extends ApiError { 221 | constructor(error: ApiError) { 222 | super(error); 223 | Object.setPrototypeOf(this, new.target.prototype); 224 | } 225 | 226 | getActualType() { 227 | return { 228 | status: this.status, 229 | data: this.data, 230 | } as OpErrorType; 231 | } 232 | }; 233 | 234 | return fun; 235 | } 236 | 237 | function fetcher() { 238 | let baseUrl = ""; 239 | let defaultInit: RequestInit = {}; 240 | const middlewares: Middleware[] = []; 241 | const fetch = wrapMiddlewares(middlewares, fetchJson); 242 | 243 | return { 244 | configure: (config: FetchConfig) => { 245 | baseUrl = config.baseUrl ?? ""; 246 | defaultInit = config.init ?? {}; 247 | middlewares.splice(0); 248 | middlewares.push(...(config.use ?? [])); 249 | }, 250 | use: (mw: Middleware) => middlewares.push(mw), 251 | path:

(path: P) => ({ 252 | method: (method: M) => ({ 253 | create: ((queryParams?: Record) => 254 | createFetch( 255 | async (payload, init) => 256 | await fetchUrl({ 257 | baseUrl: baseUrl ?? "", 258 | path: path as string, 259 | method: method as Method, 260 | queryParams: Object.keys(queryParams != null || {}), 261 | payload, 262 | init: mergeRequestInit(defaultInit, init), 263 | fetch, 264 | }) 265 | )) as CreateFetch, 266 | }), 267 | }), 268 | }; 269 | } 270 | 271 | export const Fetcher = { 272 | for: >() => fetcher(), 273 | }; 274 | -------------------------------------------------------------------------------- /test/thirdparty.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import { deepStrictEqual, strictEqual } from "assert"; 3 | import { describe, test } from "mocha"; 4 | import { getAccounts } from "@tableland/local"; 5 | import { 6 | D1Orm, 7 | DataTypes, 8 | Model, 9 | GenerateQuery, 10 | QueryType, 11 | type Infer, 12 | } from "d1-orm"; 13 | import sql, { type FormatConfig } from "@databases/sql"; 14 | import { escapeSQLiteIdentifier } from "@databases/escape-identifier"; 15 | import { NonceManager } from "@ethersproject/experimental"; 16 | import { getDefaultProvider } from "../src/helpers/index.js"; 17 | import { Database } from "../src/index.js"; 18 | import { TEST_TIMEOUT_FACTOR } from "./setup"; 19 | 20 | describe("thirdparty", function () { 21 | this.timeout(TEST_TIMEOUT_FACTOR * 10000); 22 | 23 | // Note that we're using the second account here 24 | const [, wallet] = getAccounts(); 25 | const provider = getDefaultProvider("http://127.0.0.1:8545"); 26 | // const signer = wallet.connect(provider); 27 | const baseSigner = wallet.connect(provider); 28 | // Also demonstrates the nonce manager usage 29 | const signer = new NonceManager(baseSigner); 30 | const db = new Database({ signer }); 31 | 32 | describe("d1-orm", function () { 33 | const orm = new D1Orm(db); 34 | 35 | // We'll define our core model up here and use it in tests below 36 | const users = new Model( 37 | { 38 | D1Orm: orm, 39 | tableName: "users", 40 | primaryKeys: "id", 41 | uniqueKeys: [["email"]], 42 | }, 43 | { 44 | id: { 45 | type: DataTypes.INTEGER, 46 | notNull: true, 47 | }, 48 | name: { 49 | type: DataTypes.STRING, 50 | notNull: true, 51 | // See https://github.com/Interactions-as-a-Service/d1-orm/issues/60 52 | // defaultValue: "John Doe", 53 | }, 54 | email: { 55 | type: DataTypes.STRING, 56 | }, 57 | } 58 | ); 59 | type User = Infer; 60 | 61 | this.beforeAll(async function () { 62 | const create = await users.CreateTable({ 63 | strategy: "default", 64 | }); 65 | await create.meta.txn.wait(); 66 | 67 | // TODO: Find a nicer way to deal with this... 68 | (users.tableName as any) = create.meta.txn.name; 69 | }); 70 | 71 | test("where a basic model is used to create data", async function () { 72 | await users.InsertOne({ 73 | name: "Bobby Tables", 74 | email: "bobby-tab@gmail.com", 75 | }); 76 | 77 | const [result] = await users.InsertMany([ 78 | { 79 | name: "Bobby Tables", 80 | email: "bob-tables@gmail.com", 81 | }, 82 | { 83 | name: "Jane Tables", 84 | email: "janet@gmail.com", 85 | }, 86 | ]); 87 | 88 | await result.meta.txn.wait(); 89 | 90 | const { results } = await users.All({ 91 | where: { name: "Bobby Tables" }, 92 | limit: 1, 93 | offset: 0, 94 | orderBy: ["id"], 95 | }); 96 | 97 | deepStrictEqual(results, [ 98 | { 99 | name: "Bobby Tables", 100 | id: 1, 101 | email: "bobby-tab@gmail.com", 102 | }, 103 | ]); 104 | }); 105 | 106 | test("basic query building works well to then query the data", async function () { 107 | const { query, bindings } = GenerateQuery( 108 | QueryType.SELECT, 109 | users.tableName, // Could also come from the above meta.txn objects 110 | { 111 | where: { 112 | name: "Bobby Tables", 113 | }, // this uses the type from above to enforce it to properties which exist on the table 114 | limit: 1, // we only want the first user 115 | offset: 1, // skip the first user named 'Bobby Tables' when performing this query 116 | 117 | // Using orderBy is a special case, so there's a few possible syntaxes for it 118 | orderBy: { column: "id", descending: true }, // ORDER BY id DESC NULLS LAST 119 | } 120 | ); 121 | 122 | // Using the database directly 123 | const stmt = db.prepare(query).bind(bindings); 124 | const { results } = await stmt.all(); 125 | deepStrictEqual(results, [ 126 | { 127 | name: "Bobby Tables", 128 | id: 1, 129 | email: "bobby-tab@gmail.com", 130 | }, 131 | ]); 132 | }); 133 | 134 | test("where upserts are easier when using an orm", async function () { 135 | const user: User = { 136 | id: 1, 137 | name: "John Doe", 138 | email: "john-doe@gmail.com", 139 | }; 140 | const { query, bindings } = GenerateQuery( 141 | QueryType.UPSERT, 142 | users.tableName, // Could also come from the above meta.txn objects 143 | { 144 | data: user, 145 | upsertOnlyUpdateData: { 146 | name: user.name, 147 | email: user.email, 148 | }, 149 | where: { 150 | id: user.id, 151 | }, 152 | }, 153 | "id" 154 | ); 155 | // { 156 | // query: "INSERT INTO users (id, name, email) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = ?, email = ? WHERE id = ?", 157 | // bindings: [1, "John Doe", "john-doe@gmail.com", "John Doe", "john-doe@gmail.com", 1] 158 | // } 159 | 160 | // Using the database directly 161 | const stmt = db.prepare(query).bind(bindings); 162 | const { meta } = await stmt.run(); 163 | const receipt = await (meta.txn as any).wait(); 164 | strictEqual(receipt?.error, undefined); 165 | 166 | const results = await db 167 | .prepare(`SELECT * FROM ${users.tableName} WHERE id=?`) 168 | .bind(user.id) 169 | .first(); 170 | deepStrictEqual(results, user); 171 | }); 172 | }); 173 | 174 | describe("@databases/sql", function () { 175 | // See https://www.atdatabases.org/docs/sqlite 176 | const sqliteFormat: FormatConfig = { 177 | escapeIdentifier: (str) => escapeSQLiteIdentifier(str), 178 | formatValue: (value) => ({ placeholder: "?", value }), 179 | }; 180 | let tableName: string; 181 | 182 | this.beforeAll(async function () { 183 | this.timeout(TEST_TIMEOUT_FACTOR * 10000); 184 | 185 | // First, we'll test out using sql identifiers 186 | const primaryKey = sql.ident("id"); 187 | const query = sql`CREATE TABLE test_sql (${primaryKey} integer primary key, counter integer, info text);`; 188 | const { text, values } = query.format(sqliteFormat); 189 | const { meta } = await db.prepare(text).bind(values).run(); 190 | const { name } = await meta.txn!.wait(); 191 | tableName = name!; 192 | }); 193 | 194 | test("inserting rows with interpolated values is possible", async function () { 195 | const one = 1; 196 | const four = 4; 197 | const three = sql.value("three"); 198 | // Here's a safer way to inject table names 199 | const query = sql`INSERT INTO ${sql.ident( 200 | tableName 201 | )} (counter, ${sql.ident("info")}) 202 | VALUES (${one}, 'one'), (2, 'two'), (3, ${three}), (${four}, 'four');`; 203 | const { text, values } = query.format(sqliteFormat); 204 | const { meta } = await db.prepare(text).bind(values).run(); 205 | await meta.txn?.wait(); 206 | strictEqual(typeof meta.txn?.transactionHash, "string"); 207 | strictEqual(meta.txn?.transactionHash.length, 66); 208 | strictEqual(meta.duration > 0, true); 209 | }); 210 | 211 | test("querying is quite easy when using @database/sql", async function () { 212 | const boundValue = 3; 213 | const query = sql`SELECT * FROM ${sql.ident( 214 | tableName 215 | )} WHERE counter >= ${boundValue};`; 216 | const { text, values } = query.format(sqliteFormat); 217 | const { results } = await db.prepare(text).bind(values).all(); 218 | deepStrictEqual(results, [ 219 | { id: 3, counter: 3, info: "three" }, 220 | { id: 4, counter: 4, info: "four" }, 221 | ]); 222 | }); 223 | }); 224 | }); 225 | -------------------------------------------------------------------------------- /test/aliases.test.ts: -------------------------------------------------------------------------------- 1 | import url from "node:url"; 2 | import path from "node:path"; 3 | import fs from "node:fs"; 4 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 5 | import { strictEqual, rejects } from "assert"; 6 | import { describe, test } from "mocha"; 7 | import { getAccounts } from "@tableland/local"; 8 | import { 9 | type NameMapping, 10 | getDefaultProvider, 11 | jsonFileAliases, 12 | } from "../src/helpers/index.js"; 13 | import { Database } from "../src/index.js"; 14 | import { TEST_TIMEOUT_FACTOR } from "./setup"; 15 | 16 | /* eslint-disable @typescript-eslint/naming-convention */ 17 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 18 | 19 | describe("aliases", function () { 20 | this.timeout(TEST_TIMEOUT_FACTOR * 10000); 21 | // Note that we're using the second account here 22 | const [, wallet] = getAccounts(); 23 | const provider = getDefaultProvider("http://127.0.0.1:8545"); 24 | const signer = wallet.connect(provider); 25 | 26 | describe("in memory aliases", function () { 27 | // keeping name mappings in memory during these tests, but in practice 28 | // this map needs to be persisted for the entire life of the aliases 29 | const nameMap: NameMapping = {}; 30 | 31 | const db = new Database({ 32 | signer, 33 | // this parameter is the core of the aliases feature 34 | aliases: { 35 | read: async function () { 36 | return nameMap; 37 | }, 38 | write: async function (names) { 39 | for (const uuTableName in names) { 40 | nameMap[uuTableName] = names[uuTableName]; 41 | } 42 | }, 43 | }, 44 | }); 45 | 46 | test("running create statement adds name to aliases", async function () { 47 | const tablePrefix = "aliases_table"; 48 | const { meta } = await db 49 | .prepare(`CREATE TABLE ${tablePrefix} (counter int, info text);`) 50 | .all(); 51 | const uuTableName = meta.txn?.name ?? ""; 52 | 53 | strictEqual(nameMap[tablePrefix], uuTableName); 54 | }); 55 | 56 | test("insert and select uses aliases table name mappings", async function () { 57 | await db 58 | .prepare( 59 | "CREATE TABLE students (first_name text, last_name text);" 60 | // testing`first` here 61 | ) 62 | .first(); 63 | 64 | const { meta } = await db 65 | .prepare( 66 | "INSERT INTO students (first_name, last_name) VALUES ('Bobby', 'Tables');" 67 | // testing`run` here 68 | ) 69 | .run(); 70 | 71 | await meta.txn?.wait(); 72 | 73 | const { results } = await db 74 | .prepare( 75 | `SELECT * FROM students;` 76 | // testing `all` here 77 | // with 'run' and 'first' above this touches all of the single statement methods 78 | ) 79 | .all<{ first_name: string; last_name: string }>(); 80 | 81 | strictEqual(results.length, 1); 82 | strictEqual(results[0].first_name, "Bobby"); 83 | strictEqual(results[0].last_name, "Tables"); 84 | }); 85 | 86 | test("batch create uses aliases table name mappings", async function () { 87 | const prefixes = ["batch_table1", "batch_table2", "batch_table3"]; 88 | 89 | const [{ meta }] = await db.batch([ 90 | db.prepare(`CREATE TABLE ${prefixes[0]} (counter int, info text);`), 91 | db.prepare(`CREATE TABLE ${prefixes[1]} (counter int, info text);`), 92 | db.prepare(`CREATE TABLE ${prefixes[2]} (counter int, info text);`), 93 | ]); 94 | 95 | const uuNames = meta.txn?.names ?? []; 96 | 97 | strictEqual(nameMap[prefixes[0]], uuNames[0]); 98 | strictEqual(nameMap[prefixes[1]], uuNames[1]); 99 | strictEqual(nameMap[prefixes[2]], uuNames[2]); 100 | }); 101 | 102 | test("batch mutate uses aliases table name mappings", async function () { 103 | await db.prepare("CREATE TABLE mutate_test (k text, val text);").first(); 104 | 105 | const [{ meta }] = await db.batch([ 106 | db.prepare( 107 | "INSERT INTO mutate_test (k, val) VALUES ('token1', 'asdfgh');" 108 | ), 109 | db.prepare( 110 | "INSERT INTO mutate_test (k, val) VALUES ('token2', 'qwerty');" 111 | ), 112 | db.prepare( 113 | "INSERT INTO mutate_test (k, val) VALUES ('token3', 'zxcvbn');" 114 | ), 115 | ]); 116 | 117 | await meta.txn?.wait(); 118 | 119 | const { results } = await db 120 | .prepare(`SELECT * FROM mutate_test;`) 121 | .all<{ k: string; val: string }>(); 122 | 123 | strictEqual(results.length, 3); 124 | strictEqual(results[0].k, "token1"); 125 | strictEqual(results[1].k, "token2"); 126 | strictEqual(results[2].k, "token3"); 127 | strictEqual(results[0].val, "asdfgh"); 128 | strictEqual(results[1].val, "qwerty"); 129 | strictEqual(results[2].val, "zxcvbn"); 130 | }); 131 | 132 | test("batch select uses aliases table name mappings", async function () { 133 | const prefixes = ["batch_select1", "batch_select2", "batch_select3"]; 134 | 135 | await db.batch([ 136 | db.prepare(`CREATE TABLE ${prefixes[0]} (counter int);`), 137 | db.prepare(`CREATE TABLE ${prefixes[1]} (counter int);`), 138 | db.prepare(`CREATE TABLE ${prefixes[2]} (counter int);`), 139 | ]); 140 | 141 | const [{ meta }] = await db.batch([ 142 | db.prepare(`INSERT INTO ${prefixes[0]} (counter) VALUES (1);`), 143 | db.prepare(`INSERT INTO ${prefixes[1]} (counter) VALUES (2);`), 144 | db.prepare(`INSERT INTO ${prefixes[2]} (counter) VALUES (3);`), 145 | ]); 146 | 147 | await meta.txn?.wait(); 148 | 149 | const results = await db.batch<{ counter: number }>([ 150 | db.prepare(`SELECT * FROM ${prefixes[0]};`), 151 | db.prepare(`SELECT * FROM ${prefixes[1]};`), 152 | db.prepare(`SELECT * FROM ${prefixes[2]};`), 153 | ]); 154 | 155 | strictEqual(results.length, 3); 156 | strictEqual(results[0].results.length, 1); 157 | strictEqual(results[1].results.length, 1); 158 | strictEqual(results[2].results.length, 1); 159 | strictEqual(results[0].results[0].counter, 1); 160 | strictEqual(results[1].results[0].counter, 2); 161 | strictEqual(results[2].results[0].counter, 3); 162 | }); 163 | 164 | test("using universal unique table name works with aliases", async function () { 165 | const { meta } = await db 166 | .prepare("CREATE TABLE uu_name (counter int);") 167 | .all(); 168 | const uuTableName = meta.txn?.name ?? ""; 169 | 170 | const { meta: insertMeta } = await db 171 | .prepare(`INSERT INTO ${uuTableName} (counter) VALUES (1);`) 172 | .all(); 173 | 174 | await insertMeta.txn?.wait(); 175 | 176 | const { results } = await db 177 | .prepare(`SELECT * FROM ${uuTableName};`) 178 | .all<{ counter: number }>(); 179 | 180 | strictEqual(results.length, 1); 181 | strictEqual(results[0].counter, 1); 182 | }); 183 | 184 | test("creating a table with an existing prefix throws", async function () { 185 | const tablePrefix = "duplicate_name"; 186 | await db 187 | .prepare(`CREATE TABLE ${tablePrefix} (counter int, info text);`) 188 | .all(); 189 | 190 | await rejects( 191 | db 192 | .prepare(`CREATE TABLE ${tablePrefix} (counter int, info text);`) 193 | .all(), 194 | "table name already exists in aliases" 195 | ); 196 | }); 197 | }); 198 | 199 | describe("json file aliases", function () { 200 | const aliasesDir = path.join(__dirname, "aliases"); 201 | const aliasesFile = path.join(aliasesDir, "json-file-aliases.json"); 202 | try { 203 | fs.mkdirSync(aliasesDir); 204 | } catch (err) {} 205 | // reset the aliases file, and ensure the helper 206 | // creates the file if it doesn't exist 207 | try { 208 | fs.unlinkSync(aliasesFile); 209 | } catch (err) {} 210 | 211 | const db = new Database({ 212 | signer, 213 | // use the built-in SDK helper to setup and manage json aliases files 214 | aliases: jsonFileAliases(aliasesFile), 215 | }); 216 | 217 | this.afterAll(function () { 218 | try { 219 | fs.unlinkSync(aliasesFile); 220 | } catch (err) {} 221 | }); 222 | 223 | test("running create statement adds name to aliases", async function () { 224 | const tablePrefix = "json_aliases_table"; 225 | const { meta } = await db 226 | .prepare(`CREATE TABLE ${tablePrefix} (counter int, info text);`) 227 | .all(); 228 | 229 | const uuTableName = meta.txn?.name ?? ""; 230 | const nameMap = (await db.config.aliases?.read()) ?? {}; 231 | 232 | strictEqual(nameMap[tablePrefix], uuTableName); 233 | }); 234 | }); 235 | }); 236 | -------------------------------------------------------------------------------- /src/registry/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type TransactionReceipt, 3 | pollTransactionReceipt, 4 | } from "../validator/receipt.js"; 5 | import { type Runnable } from "../registry/index.js"; 6 | import { normalize } from "../helpers/index.js"; 7 | import { type SignalAndInterval, type Wait } from "../helpers/await.js"; 8 | import { 9 | type Config, 10 | type ReadConfig, 11 | extractBaseUrl, 12 | extractChainId, 13 | } from "../helpers/config.js"; 14 | import { 15 | type ContractTransaction, 16 | getContractReceipt, 17 | } from "../helpers/ethers.js"; 18 | import { validateTables, type StatementType } from "../helpers/parser.js"; 19 | 20 | /** 21 | * WaitableTransactionReceipt represents a named TransactionReceipt with a wait method. 22 | * See the Validator spec in the docs for more details. 23 | * @typedef {Object} WaitableTransactionReceipt 24 | * @property {function} wait - Async function that will not return until the validator has processed tx. 25 | * @property {string} name - The full table name. 26 | * @property {string} prefix - The table name prefix. 27 | * @property {number} chainId - The chainId of tx. 28 | * @property {string} tableId - The tableId of tx. 29 | * @property {string} transaction_hash - The transaction hash of tx. 30 | * @property {number} block_number - The block number of tx. 31 | * @property {Object} error - The first error encounntered when the Validator processed tx. 32 | * @property {number} error_event_idx - The index of the event that cause the error when the Validator processed tx. 33 | */ 34 | export type WaitableTransactionReceipt = TransactionReceipt & 35 | Wait & 36 | Named; 37 | 38 | /** 39 | * Named represents a named table with a prefix. 40 | */ 41 | export interface Named { 42 | /** 43 | * @custom:deprecated First table's full name. 44 | */ 45 | name: string; 46 | /** 47 | * @custom:deprecated First table name prefix. 48 | */ 49 | prefix: string; 50 | /** 51 | * The full table names 52 | */ 53 | names: string[]; 54 | /** 55 | * The table prefixes 56 | */ 57 | prefixes: string[]; 58 | } 59 | 60 | /** 61 | * ExtractedStatement represents a SQL statement string with the type and tables extracted. 62 | */ 63 | export interface ExtractedStatement { 64 | /** 65 | * SQL statement string. 66 | */ 67 | sql: string; 68 | /** 69 | * List of table names referenced within the statement. 70 | */ 71 | tables: string[]; 72 | /** 73 | * The statement type. Must be one of "read", "write", "create", or "acl". 74 | */ 75 | type: StatementType; 76 | } 77 | 78 | function isTransactionReceipt(arg: any): arg is WaitableTransactionReceipt { 79 | return ( 80 | !Array.isArray(arg) && 81 | arg.transactionHash != null && 82 | arg.tableId != null && 83 | arg.chainId != null && 84 | arg.blockNumber != null && 85 | typeof arg.wait === "function" 86 | ); 87 | } 88 | 89 | export function wrapResult( 90 | resultsOrReceipt: T[] | WaitableTransactionReceipt, 91 | duration: number 92 | ): Result { 93 | const meta: Metadata = { duration }; 94 | const result: Result = { 95 | meta, 96 | success: true, 97 | results: [], 98 | }; 99 | if (isTransactionReceipt(resultsOrReceipt)) { 100 | return { ...result, meta: { ...meta, txn: resultsOrReceipt } }; 101 | } 102 | return { ...result, results: resultsOrReceipt }; 103 | } 104 | 105 | /** 106 | * Metadata represents meta information about an executed statement/transaction. 107 | */ 108 | export interface Metadata { 109 | /** 110 | * Total client-side duration of the async call. 111 | */ 112 | duration: number; 113 | /** 114 | * The optional transactionn information receipt. 115 | */ 116 | txn?: WaitableTransactionReceipt; 117 | /** 118 | * Metadata may contrain additional arbitrary key/values pairs. 119 | */ 120 | [key: string]: any; 121 | } 122 | 123 | /** 124 | * Result represents the core return result for an executed statement. 125 | */ 126 | export interface Result { 127 | /** 128 | * Possibly empty list of query results. 129 | */ 130 | results: T[]; 131 | /** 132 | * Whether the query or transaction was successful. 133 | */ 134 | success: boolean; // almost always true 135 | /** 136 | * If there was an error, this will contain the error string. 137 | */ 138 | error?: string; 139 | /** 140 | * Additional meta information. 141 | */ 142 | meta: Metadata; 143 | } 144 | 145 | export async function extractReadonly( 146 | conn: Config, 147 | { tables, type }: Omit 148 | ): Promise { 149 | const [{ chainId }] = await validateTables({ tables, type }); 150 | const baseUrl = await extractBaseUrl(conn, chainId); 151 | return { baseUrl }; 152 | } 153 | 154 | /** 155 | * Given a config, a table name prefix, and a transaction that only affects a single table 156 | * this will enable waiting for the Validator to materialize the change in the transaction 157 | * @param {Object} conn - A Database config. 158 | * @param {string} prefix - A table name prefix. 159 | * @param {Object} tx - A transaction object that includes a call to the Registry Contract. 160 | * @returns {WaitableTransactionReceipt} 161 | */ 162 | export async function wrapTransaction( 163 | conn: Config, 164 | prefix: string, 165 | tx: ContractTransaction 166 | ): Promise { 167 | // TODO: next major we should combine this with wrapManyTransaction 168 | const _params = await getContractReceipt(tx); 169 | const chainId = 170 | _params.chainId === 0 || _params.chainId == null 171 | ? await extractChainId(conn) 172 | : _params.chainId; 173 | const name = `${prefix}_${chainId}_${_params.tableIds[0]}`; 174 | const params = { ..._params, chainId, tableId: _params.tableIds[0] }; 175 | const wait = async ( 176 | opts: SignalAndInterval = {} 177 | ): Promise => { 178 | const receipt = await pollTransactionReceipt(conn, params, opts); 179 | if (receipt.error != null) { 180 | throw new Error(receipt.error); 181 | } 182 | return { ...receipt, name, prefix, prefixes: [prefix], names: [name] }; 183 | }; 184 | return { ...params, wait, name, prefix, prefixes: [prefix], names: [name] }; 185 | } 186 | 187 | /* A helper function for mapping contract event receipts to table data 188 | * 189 | * @param {conn} a database config object 190 | * @param {statements} either the sql statement strings or the nomralized statement objects that were used in the transaction 191 | * @param {tx} the transaction object 192 | * @returns {(WaitableTransactionReceipt & Named)} 193 | * 194 | */ 195 | export async function wrapManyTransaction( 196 | conn: Config, 197 | statements: string[] | Runnable[], 198 | tx: ContractTransaction 199 | ): Promise { 200 | const _params = await getContractReceipt(tx); 201 | const chainId = 202 | _params.chainId === 0 || _params.chainId == null 203 | ? await extractChainId(conn) 204 | : _params.chainId; 205 | 206 | // map the transaction events to table names and prefixes then return them to the caller 207 | const { names, prefixes } = ( 208 | await Promise.all( 209 | _params.tableIds.map(async function (tableId: string, i: number) { 210 | const statementString = isRunnable(statements[i]) 211 | ? (statements[i] as Runnable).statement 212 | : (statements[i] as string); 213 | const normalized = await normalize(statementString); 214 | 215 | if (normalized.type === "create") { 216 | return { 217 | name: `${normalized.tables[0]}_${chainId}_${tableId}`, 218 | prefix: normalized.tables[0], 219 | }; 220 | } 221 | return { 222 | name: normalized.tables[0], 223 | prefix: normalized.tables[0].split("_").slice(0, -2).join("_"), 224 | }; 225 | }) 226 | ) 227 | ).reduce<{ prefixes: string[]; names: string[] }>( 228 | function (acc, cur) { 229 | acc.prefixes.push(cur.prefix); 230 | acc.names.push(cur.name); 231 | return acc; 232 | }, 233 | { prefixes: [], names: [] } 234 | ); 235 | 236 | const params = { ..._params, chainId }; 237 | // TODO: including `name`, `prefix`, and `tableId` for back compat, will be removed next major 238 | const tableMeta = { 239 | names, 240 | name: names[0], 241 | tableId: _params.tableIds[0], 242 | prefixes, 243 | prefix: prefixes[0], 244 | }; 245 | 246 | const wait = async ( 247 | opts: SignalAndInterval = {} 248 | ): Promise => { 249 | const receipt = await pollTransactionReceipt(conn, params, opts); 250 | if (receipt.error != null) { 251 | throw new Error(receipt.error); 252 | } 253 | 254 | return { 255 | ...receipt, 256 | ...tableMeta, 257 | }; 258 | }; 259 | 260 | return { 261 | ...params, 262 | wait, 263 | ...tableMeta, 264 | }; 265 | } 266 | 267 | function isRunnable(statement: string | Runnable): statement is Runnable { 268 | return (statement as Runnable).tableId !== undefined; 269 | } 270 | -------------------------------------------------------------------------------- /src/validator/client/validator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was auto-generated by openapi-typescript. 3 | * Do not make direct changes to the file. 4 | */ 5 | 6 | 7 | export interface paths { 8 | "/health": { 9 | /** 10 | * Get health status 11 | * @description Returns OK if the validator considers itself healthy. 12 | */ 13 | get: operations["health"]; 14 | }; 15 | "/version": { 16 | /** 17 | * Get version information 18 | * @description Returns version information about the validator daemon. 19 | */ 20 | get: operations["version"]; 21 | }; 22 | "/query": { 23 | /** 24 | * Query the network 25 | * @description Returns the results of a SQL read query against the Tabeland network 26 | */ 27 | get: operations["queryByStatement"]; 28 | }; 29 | "/receipt/{chainId}/{transactionHash}": { 30 | /** 31 | * Get transaction status 32 | * @description Returns the status of a given transaction receipt by hash 33 | */ 34 | get: operations["receiptByTransactionHash"]; 35 | }; 36 | "/tables/{chainId}/{tableId}": { 37 | /** 38 | * Get table information 39 | * @description Returns information about a single table, including schema information 40 | */ 41 | get: operations["getTableById"]; 42 | }; 43 | } 44 | 45 | export type webhooks = Record; 46 | 47 | export interface components { 48 | schemas: { 49 | readonly Table: { 50 | /** @example healthbot_80001_1 */ 51 | readonly name?: string; 52 | /** @example https://testnets.tableland.network/api/v1/tables/80001/1 */ 53 | readonly external_url?: string; 54 | /** @example https://tables.testnets.tableland.xyz/80001/1.html */ 55 | readonly animation_url?: string; 56 | /** @example https://tables.testnets.tableland.xyz/80001/1.svg */ 57 | readonly image?: string; 58 | /** 59 | * @example { 60 | * "display_type": "date", 61 | * "trait_type": "created", 62 | * "value": 1657113720 63 | * } 64 | */ 65 | readonly attributes?: readonly ({ 66 | /** @description The display type for marketplaces */ 67 | readonly display_type?: string; 68 | /** @description The trait type for marketplaces */ 69 | readonly trait_type?: string; 70 | /** @description The value of the property */ 71 | readonly value?: string | number | boolean | Record; 72 | })[]; 73 | readonly schema?: components["schemas"]["Schema"]; 74 | }; 75 | readonly TransactionReceipt: { 76 | /** 77 | * @deprecated 78 | * @description This field is deprecated 79 | * @example 1 80 | */ 81 | readonly table_id?: string; 82 | /** 83 | * @example [ 84 | * "1", 85 | * "2" 86 | * ] 87 | */ 88 | readonly table_ids?: readonly (string)[]; 89 | /** @example 0x02f319429b8a7be1cbb492f0bfbf740d2472232a2edadde7df7c16c0b61aa78b */ 90 | readonly transaction_hash?: string; 91 | /** 92 | * Format: int64 93 | * @example 27055540 94 | */ 95 | readonly block_number?: number; 96 | /** 97 | * Format: int32 98 | * @example 80001 99 | */ 100 | readonly chain_id?: number; 101 | /** @example The query statement is invalid */ 102 | readonly error?: string; 103 | /** 104 | * Format: int32 105 | * @example 1 106 | */ 107 | readonly error_event_idx?: number; 108 | }; 109 | readonly Schema: { 110 | readonly columns?: readonly (components["schemas"]["Column"])[]; 111 | /** 112 | * @example [ 113 | * "PRIMARY KEY (id)" 114 | * ] 115 | */ 116 | readonly table_constraints?: readonly (string)[]; 117 | }; 118 | readonly Column: { 119 | /** @example id */ 120 | readonly name?: string; 121 | /** @example integer */ 122 | readonly type?: string; 123 | /** 124 | * @example [ 125 | * "NOT NULL", 126 | * "PRIMARY KEY", 127 | * "UNIQUE" 128 | * ] 129 | */ 130 | readonly constraints?: readonly (string)[]; 131 | }; 132 | readonly VersionInfo: { 133 | /** 134 | * Format: int32 135 | * @example 0 136 | */ 137 | readonly version?: number; 138 | /** @example 79688910d4689dcc0991a0d8eb9d988200586d8f */ 139 | readonly git_commit?: string; 140 | /** @example foo/experimentalfeature */ 141 | readonly git_branch?: string; 142 | /** @example dirty */ 143 | readonly git_state?: string; 144 | /** @example v1.2.3_dirty */ 145 | readonly git_summary?: string; 146 | /** @example 2022-11-29T16:28:04Z */ 147 | readonly build_date?: string; 148 | /** @example v1.0.1 */ 149 | readonly binary_version?: string; 150 | }; 151 | }; 152 | responses: never; 153 | parameters: never; 154 | requestBodies: never; 155 | headers: never; 156 | pathItems: never; 157 | } 158 | 159 | export type external = Record; 160 | 161 | export interface operations { 162 | 163 | /** 164 | * Get health status 165 | * @description Returns OK if the validator considers itself healthy. 166 | */ 167 | health: { 168 | responses: { 169 | /** @description The validator is healthy. */ 170 | 200: never; 171 | }; 172 | }; 173 | /** 174 | * Get version information 175 | * @description Returns version information about the validator daemon. 176 | */ 177 | version: { 178 | responses: { 179 | /** @description successful operation */ 180 | 200: { 181 | content: { 182 | readonly "application/json": components["schemas"]["VersionInfo"]; 183 | }; 184 | }; 185 | }; 186 | }; 187 | /** 188 | * Query the network 189 | * @description Returns the results of a SQL read query against the Tabeland network 190 | */ 191 | queryByStatement: { 192 | parameters: { 193 | query: { 194 | /** 195 | * @description The SQL read query statement 196 | * @example select * from healthbot_80001_1 197 | */ 198 | statement: string; 199 | /** 200 | * @description The requested response format: 201 | * * `objects` - Returns the query results as a JSON array of JSON objects. 202 | * * `table` - Return the query results as a JSON object with columns and rows properties. 203 | */ 204 | format?: "objects" | "table"; 205 | /** @description Whether to extract the JSON object from the single property of the surrounding JSON object. */ 206 | extract?: boolean; 207 | /** @description Whether to unwrap the returned JSON objects from their surrounding array. */ 208 | unwrap?: boolean; 209 | }; 210 | }; 211 | responses: { 212 | /** @description Successful operation */ 213 | 200: { 214 | content: { 215 | readonly "application/json": Record; 216 | }; 217 | }; 218 | /** @description Invalid query/statement value */ 219 | 400: never; 220 | /** @description Row Not Found */ 221 | 404: never; 222 | /** @description Too Many Requests */ 223 | 429: never; 224 | }; 225 | }; 226 | /** 227 | * Get transaction status 228 | * @description Returns the status of a given transaction receipt by hash 229 | */ 230 | receiptByTransactionHash: { 231 | parameters: { 232 | path: { 233 | /** 234 | * @description The parent chain to target 235 | * @example 80001 236 | */ 237 | chainId: number; 238 | /** 239 | * @description The transaction hash to request 240 | * @example 0x02f319429b8a7be1cbb492f0bfbf740d2472232a2edadde7df7c16c0b61aa78b 241 | */ 242 | transactionHash: string; 243 | }; 244 | }; 245 | responses: { 246 | /** @description successful operation */ 247 | 200: { 248 | content: { 249 | readonly "application/json": components["schemas"]["TransactionReceipt"]; 250 | }; 251 | }; 252 | /** @description Invalid chain identifier or transaction hash format */ 253 | 400: never; 254 | /** @description No transaction receipt found with the provided hash */ 255 | 404: never; 256 | /** @description Too Many Requests */ 257 | 429: never; 258 | }; 259 | }; 260 | /** 261 | * Get table information 262 | * @description Returns information about a single table, including schema information 263 | */ 264 | getTableById: { 265 | parameters: { 266 | path: { 267 | /** 268 | * @description The parent chain to target 269 | * @example 80001 270 | */ 271 | chainId: number; 272 | /** 273 | * @description Table identifier 274 | * @example 1 275 | */ 276 | tableId: string; 277 | }; 278 | }; 279 | responses: { 280 | /** @description successful operation */ 281 | 200: { 282 | content: { 283 | readonly "application/json": components["schemas"]["Table"]; 284 | }; 285 | }; 286 | /** @description Invalid chain or table identifier */ 287 | 400: never; 288 | /** @description Table Not Found */ 289 | 404: never; 290 | /** @description Too Many Requests */ 291 | 429: never; 292 | /** @description Internal Server Error */ 293 | 500: never; 294 | }; 295 | }; 296 | } 297 | -------------------------------------------------------------------------------- /src/helpers/subscribe.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import asyncGenFromEmit from "@async-generators/from-emitter"; 3 | import { type TablelandTables } from "@tableland/evm"; 4 | import { pollTransactionReceipt } from "../validator/receipt.js"; 5 | import { 6 | getTableIdentifier, 7 | getContractAndOverrides, 8 | type TableIdentifier, 9 | } from "../registry/contract.js"; 10 | import { extractBaseUrl, type Config } from "../helpers/index.js"; 11 | 12 | // @ts-expect-error Seems like this package isn't setup to work with modern esm + ts 13 | const fromEmitter = asyncGenFromEmit.default; 14 | 15 | type ContractMap = Record; 16 | 17 | interface ContractEventListener { 18 | eventName: string; 19 | eventListener: (...args: any[]) => void; 20 | } 21 | 22 | type ListenerMap = Record< 23 | // The key is the listenerId, which is _{chainId}_{tableId} 24 | string, 25 | { 26 | chainId: number; 27 | tableId: string; 28 | emitter: EventEmitter; 29 | contractListeners: ContractEventListener[]; 30 | } 31 | >; 32 | 33 | type ContractEventTableIdMap = Record< 34 | // the key is the event name in the Solidity contract 35 | string, 36 | { 37 | // `tableIdIndex` is the index of the event's argument that contains the tableId 38 | tableIdIndex: number; 39 | // `emit` is the name of the event that will be emitted by the TableEventBus instance 40 | emit: string; 41 | } 42 | >; 43 | 44 | /** 45 | * List of the Registry Contract events that will be emitted from the TableEventBus. 46 | */ 47 | const contractEvents: ContractEventTableIdMap = { 48 | RunSQL: { 49 | tableIdIndex: 2, 50 | emit: "change", 51 | }, 52 | TransferTable: { 53 | tableIdIndex: 2, 54 | emit: "transfer", 55 | }, 56 | SetController: { 57 | tableIdIndex: 0, 58 | emit: "set-controller", 59 | }, 60 | }; 61 | 62 | /** 63 | * TableEventBus provides a way to listen for: 64 | * mutations, transfers, and changes to controller 65 | */ 66 | export class TableEventBus { 67 | readonly config: Config; 68 | readonly contracts: ContractMap; 69 | readonly listeners: ListenerMap; 70 | 71 | /** 72 | * Create a TableEventBus instance with the specified connection configuration. 73 | * @param config The connection configuration. This must include an ethersjs 74 | * Signer. If passing the config from a pre-existing Database instance, it 75 | * must have a non-null signer key defined. 76 | */ 77 | constructor(config: Partial = {}) { 78 | /* c8 ignore next 3 */ 79 | if (config.signer == null) { 80 | throw new Error("missing signer information"); 81 | } 82 | 83 | this.config = config as Config; 84 | this.contracts = {}; 85 | this.listeners = {}; 86 | } 87 | 88 | /** 89 | * Start listening to the Registry Contract for events that are associated 90 | * with a given table. 91 | * There's only ever one "listener" for a table, but the emitter that 92 | * Contract listener has can have as many event listeners as the environment 93 | * supports. 94 | * @param tableName The full name of table that you want to listen for 95 | * changes to. 96 | */ 97 | async addListener(tableName: string): Promise { 98 | if (tableName == null) { 99 | throw new Error("table name is required to add listener"); 100 | } 101 | 102 | const tableIdentifier = await getTableIdentifier(tableName); 103 | const listenerId = `_${tableIdentifier.chainId}_${tableIdentifier.tableId}`; 104 | if (this.listeners[listenerId]?.emitter != null) { 105 | return this.listeners[listenerId].emitter; 106 | } 107 | 108 | const emitter = new EventEmitter(); 109 | 110 | // If not already listening to the contract we will start listening now, 111 | // if already listening we will start tracking the new emitter. 112 | const contractEventListeners = await this._ensureListening( 113 | listenerId, 114 | emitter 115 | ); 116 | 117 | this.listeners[listenerId] = { 118 | ...tableIdentifier, 119 | emitter, 120 | contractListeners: contractEventListeners, 121 | }; 122 | 123 | return emitter; 124 | } 125 | 126 | /** 127 | * A simple wrapper around `addListener` that returns an async iterable 128 | * which can be used with the for await ... of pattern. 129 | * @param tableName The full name of table that you want to listen for 130 | * changes to. 131 | */ 132 | async addTableIterator(tableName: string): Promise> { 133 | const emmiter = await this.addListener(tableName); 134 | return fromEmitter(emmiter, { 135 | onNext: "change", 136 | onError: "error", 137 | onDone: "close", 138 | }); 139 | } 140 | 141 | /** 142 | * Remove a listener (or iterator) based on chain and tableId 143 | * @param params A TableIdentifier Object. Must have `chainId` and `tableId` keys. 144 | */ 145 | removeListener(params: TableIdentifier): void { 146 | if (params == null) { 147 | throw new Error("must provide chainId and tableId to remove a listener"); 148 | } 149 | 150 | const listenerId = `_${params.chainId}_${params.tableId}`; 151 | if (this.listeners[listenerId] == null) { 152 | throw new Error("cannot remove listener that does not exist"); 153 | } 154 | 155 | const emitter = this.listeners[listenerId].emitter; 156 | emitter.removeAllListeners(); 157 | 158 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 159 | delete this.listeners[listenerId]; 160 | } 161 | 162 | // stop listening to the contract and remove all listeners 163 | removeAllListeners(): void { 164 | // Need to remove the contract listener first because removing 165 | // the table listener will delete the listeners object 166 | for (const chainId in this.contracts) { 167 | const contract = this.contracts[chainId]; 168 | for (const listenerId in this.listeners) { 169 | const listenerObj = this.listeners[listenerId]; 170 | const listenerObjChainId = listenerObj.chainId.toString(); 171 | 172 | if (listenerObjChainId === chainId) { 173 | // If the chainId of the contract and the Listener Object are the same 174 | // then we want to dig into the Listener Object and for each event that 175 | // the contract is listening to we remove the listener 176 | for (let i = 0; i < listenerObj.contractListeners.length; i++) { 177 | const listenerEventFunc = listenerObj.contractListeners[i]; 178 | contract.off( 179 | listenerEventFunc.eventName, 180 | listenerEventFunc.eventListener 181 | ); 182 | } 183 | } 184 | } 185 | } 186 | 187 | // Now that the contract listeners are gone we can remove 188 | // the emitter listeners and delete the entries 189 | for (const listener in this.listeners) { 190 | const l = this.listeners[listener]; 191 | this.removeListener({ 192 | chainId: l.chainId, 193 | tableId: l.tableId, 194 | }); 195 | } 196 | } 197 | 198 | async _getContract(chainId: number): Promise { 199 | if (this.contracts[chainId] != null) return this.contracts[chainId]; 200 | if (this.config.signer == null) { 201 | /* c8 ignore next 2 */ 202 | throw new Error("signer information is required to get contract"); 203 | } 204 | 205 | const { contract } = await getContractAndOverrides( 206 | this.config.signer, 207 | chainId 208 | ); 209 | this.contracts[chainId] = contract; 210 | 211 | return contract; 212 | } 213 | 214 | async _ensureListening( 215 | listenerId: string, 216 | emitter: EventEmitter 217 | ): Promise { 218 | const { chainId, tableId } = await getTableIdentifier(listenerId); 219 | 220 | const contract = await this._getContract(chainId); 221 | return this._attachEmitter(contract, emitter, { tableId, chainId }); 222 | } 223 | 224 | _attachEmitter( 225 | contract: TablelandTables, 226 | emitter: EventEmitter, 227 | tableIdentifier: TableIdentifier 228 | ): ContractEventListener[] { 229 | const { tableId, chainId } = tableIdentifier; 230 | const listenerEventFunctions = []; 231 | 232 | for (const key in contractEvents) { 233 | const eve = contractEvents[key]; 234 | // put the listener function in memory so we can remove it if needed 235 | const listener = (...args: any[]): void => { 236 | const _tableId = args[eve.tableIdIndex].toString(); 237 | if (_tableId !== tableId) return; 238 | if (key !== "RunSQL") { 239 | emitter.emit(eve.emit, args); 240 | } 241 | 242 | const transactionHash = args[args.length - 1].transactionHash; 243 | 244 | const poll = async (): Promise => { 245 | const baseUrl = 246 | this.config.baseUrl == null 247 | ? await extractBaseUrl({ signer: this.config.signer }) 248 | : this.config.baseUrl; 249 | 250 | const res = await pollTransactionReceipt( 251 | { baseUrl }, 252 | { transactionHash, chainId } 253 | ); 254 | 255 | emitter.emit("change", res); 256 | }; 257 | poll().catch((err) => { 258 | /* c8 ignore next 1 */ 259 | emitter.emit("error", { error: err, hash: transactionHash }); 260 | }); 261 | }; 262 | 263 | contract.on(key, listener); 264 | 265 | listenerEventFunctions.push({ 266 | eventName: key, 267 | eventListener: listener, 268 | }); 269 | } 270 | 271 | return listenerEventFunctions; 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /src/lowlevel.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Config, 3 | extractBaseUrl, 4 | extractSigner, 5 | normalize, 6 | type Signal, 7 | type ReadConfig, 8 | type NameMapping, 9 | } from "./helpers/index.js"; 10 | import { 11 | prepareCreateOne, 12 | create, 13 | type CreateManyParams, 14 | } from "./registry/create.js"; 15 | import { 16 | prepareMutateOne, 17 | mutate, 18 | type Runnable, 19 | type MutateManyParams, 20 | } from "./registry/run.js"; 21 | import { 22 | type ExtractedStatement, 23 | type WaitableTransactionReceipt, 24 | wrapTransaction, 25 | wrapManyTransaction, 26 | } from "./registry/utils.js"; 27 | import { 28 | type ObjectsFormat, 29 | type ValueOf, 30 | getQuery, 31 | } from "./validator/query.js"; 32 | import { ApiError } from "./validator/index.js"; 33 | 34 | // see `errorWithHint` for usage 35 | const hints = [ 36 | { 37 | regexp: /syntax error at position \d+ near '.+'/, 38 | template: function (statement: string, match: any): string { 39 | const location = Number(match.input.slice(match.index).split(" ")[4]); 40 | if (isNaN(location)) return ""; 41 | 42 | const termMatch = match.input.match( 43 | /syntax error at position \d+ (near '.+')/ 44 | ); 45 | if ( 46 | termMatch == null || 47 | termMatch.length < 1 || 48 | termMatch[1].indexOf("near '") !== 0 49 | ) { 50 | return ""; 51 | } 52 | 53 | // isolate the term from the matched string 54 | const term = termMatch[1].slice(6, -1); 55 | 56 | const padding = " ".repeat(location - term.length); 57 | const carrots = "^".repeat(term.length); 58 | 59 | return `${statement} 60 | ${padding}${carrots}`; 61 | }, 62 | }, 63 | { 64 | regexp: /no such column/, 65 | template: function (statement: string, match: any): string { 66 | // note: the error returned from the validator, and the one generated in the client 67 | // in the client already include the name of the column. 68 | return statement; 69 | }, 70 | }, 71 | ]; 72 | 73 | // TODO: this only works if the transaction will only be affecting a single table. 74 | // I've currently got new versions of this below called execMutateMany and 75 | // execCreateMany, but we might be able to combine all of these `exec` functions 76 | // into one when we move to version 5. 77 | export async function exec( 78 | config: Config, 79 | { type, sql, tables: [first] }: ExtractedStatement 80 | ): Promise { 81 | const signer = await extractSigner(config); 82 | const chainId = await signer.getChainId(); 83 | const baseUrl = await extractBaseUrl(config, chainId); 84 | const _config = { baseUrl, signer }; 85 | const _params = { chainId, first, statement: sql }; 86 | switch (type) { 87 | case "create": { 88 | if (typeof config.aliases?.read === "function") { 89 | const currentAliases = await config.aliases.read(); 90 | if (currentAliases[first] != null) { 91 | throw new Error("table name already exists in aliases"); 92 | } 93 | } 94 | 95 | const { prefix, ...prepared } = await prepareCreateOne(_params); 96 | const tx = await create(_config, prepared); 97 | const wrappedTx = await wrapTransaction(_config, prefix, tx); 98 | 99 | if (typeof config.aliases?.write === "function") { 100 | const uuTableName = wrappedTx.name; 101 | const nameMap: NameMapping = {}; 102 | nameMap[first] = uuTableName; 103 | 104 | await config.aliases.write(nameMap); 105 | } 106 | 107 | return wrappedTx; 108 | } 109 | /* c8 ignore next */ 110 | case "acl": 111 | case "write": { 112 | if (typeof config.aliases?.read === "function") { 113 | const nameMap = await config.aliases.read(); 114 | const norm = await normalize(_params.statement, nameMap); 115 | 116 | _params.statement = norm.statements[0]; 117 | _params.first = nameMap[first] != null ? nameMap[first] : first; 118 | } 119 | 120 | const { prefix, ...prepared } = await prepareMutateOne(_params); 121 | const tx = await mutate(_config, prepared); 122 | return await wrapTransaction(_config, prefix, tx); 123 | } 124 | /* c8 ignore next 2 */ 125 | default: 126 | throw new Error("invalid statement type: read"); 127 | } 128 | } 129 | 130 | /** 131 | * This is an internal method that will call the Registry Contract `mutate` method 132 | * with a set of Runnables. 133 | * Once the contract call finishes, this returns the mapping of the contract tx results 134 | * to the Runnables argument. 135 | */ 136 | export async function execMutateMany( 137 | config: Config, 138 | runnables: Runnable[] 139 | ): Promise { 140 | const signer = await extractSigner(config); 141 | const chainId = await signer.getChainId(); 142 | const baseUrl = await extractBaseUrl(config, chainId); 143 | const _config = { baseUrl, signer }; 144 | const params: MutateManyParams = { runnables, chainId }; 145 | 146 | if (typeof config.aliases?.read === "function") { 147 | const nameMap = await config.aliases.read(); 148 | 149 | params.runnables = await Promise.all( 150 | params.runnables.map(async function (runnable) { 151 | const norm = await normalize(runnable.statement, nameMap); 152 | runnable.statement = norm.statements[0]; 153 | 154 | return runnable; 155 | }) 156 | ); 157 | } 158 | 159 | const tx = await mutate(_config, params); 160 | 161 | return await wrapManyTransaction( 162 | _config, 163 | runnables.map((r) => r.statement), 164 | tx 165 | ); 166 | } 167 | 168 | /** 169 | * This is an internal method that will call the Registry Contract `create` method with 170 | * a set of sql create statements. 171 | * Once the contract call finishes, this returns the mapping of the contract tx results to 172 | * the create statements. 173 | */ 174 | export async function execCreateMany( 175 | config: Config, 176 | statements: string[] 177 | ): Promise { 178 | const signer = await extractSigner(config); 179 | const chainId = await signer.getChainId(); 180 | const baseUrl = await extractBaseUrl(config, chainId); 181 | const _config = { baseUrl, signer }; 182 | const params: CreateManyParams = { 183 | statements: await Promise.all( 184 | statements.map(async function (statement) { 185 | const prepared = await prepareCreateOne({ statement, chainId }); 186 | return prepared.statement; 187 | }) 188 | ), 189 | chainId, 190 | }; 191 | 192 | const tx = await create(_config, params); 193 | const wrappedTx = await wrapManyTransaction(_config, statements, tx); 194 | 195 | if (typeof config.aliases?.write === "function") { 196 | const currentAliases = await config.aliases.read(); 197 | 198 | // Collect the user provided table names to add to the aliases. 199 | const aliasesTableNames = await Promise.all( 200 | statements.map(async function (statement) { 201 | const norm = await normalize(statement); 202 | if (currentAliases[norm.tables[0]] != null) { 203 | throw new Error("table name already exists in aliases"); 204 | } 205 | return norm.tables[0]; 206 | }) 207 | ); 208 | 209 | const uuTableNames = wrappedTx.names; 210 | const nameMap: NameMapping = {}; 211 | for (let i = 0; i < aliasesTableNames.length; i++) { 212 | nameMap[aliasesTableNames[i]] = uuTableNames[i]; 213 | } 214 | 215 | await config.aliases.write(nameMap); 216 | } 217 | return wrappedTx; 218 | } 219 | 220 | export function errorWithCause(code: string, cause: Error): Error { 221 | return new Error(`${code}: ${cause.message}`, { cause }); 222 | } 223 | 224 | export function errorWithHint(statement: string, cause: Error): Error { 225 | if (cause.message == null || statement == null) return cause; 226 | 227 | let errorMessage = cause.message; 228 | try { 229 | for (let i = 0; i < hints.length; i++) { 230 | const hint = hints[i]; 231 | const match = errorMessage.match(hint.regexp); 232 | if (match == null) continue; 233 | 234 | const hintMessage = hint.template(statement, match); 235 | errorMessage += hintMessage !== "" ? `\n${hintMessage}` : ""; 236 | break; 237 | } 238 | 239 | return new Error(errorMessage, { cause }); 240 | } catch (err) { 241 | return cause; 242 | } 243 | } 244 | 245 | function catchNotFound(err: unknown): [] { 246 | if (err instanceof ApiError && err.status === 404) { 247 | return []; 248 | } 249 | throw err; 250 | } 251 | 252 | export async function queryRaw( 253 | config: ReadConfig, 254 | statement: string, 255 | opts: Signal = {} 256 | ): Promise>> { 257 | const params = { statement, format: "table" } as const; 258 | const response = await getQuery(config, params, opts) 259 | .then((res) => res.rows) 260 | .catch(catchNotFound); 261 | return response; 262 | } 263 | 264 | export async function queryAll( 265 | config: ReadConfig, 266 | statement: string, 267 | opts: Signal = {} 268 | ): Promise> { 269 | const params = { statement, format: "objects" } as const; 270 | const response = await getQuery(config, params, opts).catch(catchNotFound); 271 | return response; 272 | } 273 | 274 | export async function queryFirst( 275 | config: ReadConfig, 276 | statement: string, 277 | opts: Signal = {} 278 | ): Promise { 279 | const response = await queryAll(config, statement, opts).catch( 280 | catchNotFound 281 | ); 282 | return response.shift() ?? null; 283 | } 284 | 285 | export function extractColumn( 286 | values: T, 287 | colName: K 288 | ): T[K]; 289 | export function extractColumn( 290 | values: T[], 291 | colName: K 292 | ): Array; 293 | export function extractColumn( 294 | values: T[] | T, 295 | colName: K 296 | ): Array | T[K] { 297 | const array = Array.isArray(values) ? values : [values]; 298 | return array.map((row: T) => { 299 | if (row[colName] === undefined) { 300 | throw new Error(`no such column: ${colName.toString()}`); 301 | } 302 | return row[colName]; 303 | }); 304 | } 305 | -------------------------------------------------------------------------------- /test/subscribe.test.ts: -------------------------------------------------------------------------------- 1 | import { match, rejects, throws, strictEqual, deepStrictEqual } from "assert"; 2 | import { EventEmitter } from "events"; 3 | import { describe, test } from "mocha"; 4 | import { getDefaultProvider, Contract } from "ethers"; 5 | import { getAccounts } from "@tableland/local"; 6 | import { Database } from "../src/database.js"; 7 | import { Registry } from "../src/registry/index.js"; 8 | import { TableEventBus } from "../src/helpers/subscribe.js"; 9 | import { TEST_TIMEOUT_FACTOR } from "./setup"; 10 | 11 | describe("subscribe", function () { 12 | this.timeout(TEST_TIMEOUT_FACTOR * 10000); 13 | 14 | // Note that we're using the second account here 15 | const [, wallet, wallet2] = getAccounts(); 16 | const provider = getDefaultProvider("http://127.0.0.1:8545"); 17 | const signer = wallet.connect(provider); 18 | const db = new Database({ signer }); 19 | 20 | describe("TableEventBus", function () { 21 | const eventBus = new TableEventBus(db.config); 22 | 23 | test("using read-only Database config throws", async function () { 24 | const db = new Database(); 25 | 26 | throws( 27 | function () { 28 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 29 | const eveBus = new TableEventBus(db.config); 30 | }, 31 | { message: "missing signer information" } 32 | ); 33 | }); 34 | 35 | test("can listen for transfer event", async function () {}); 36 | test("addListener() throws if called without a table name", async function () { 37 | await rejects( 38 | async function () { 39 | // @ts-expect-error intentionally giving wrong number of args 40 | await eventBus.addListener(); 41 | }, 42 | { message: "table name is required to add listener" } 43 | ); 44 | }); 45 | 46 | test("addListener() adding the same table twice only uses one emitter", async function () { 47 | const { meta } = await db 48 | .prepare("CREATE TABLE test_table_subscribe (id integer, name text);") 49 | .run(); 50 | const tableName = meta.txn?.name ?? ""; 51 | await meta.txn?.wait(); 52 | 53 | const eventBus = new TableEventBus(db.config); 54 | deepStrictEqual(eventBus.listeners, {}); 55 | 56 | const tableIdentifier = "_" + tableName.split("_").slice(-2).join("_"); 57 | 58 | await eventBus.addListener(`${tableName}`); 59 | deepStrictEqual(Object.keys(eventBus.listeners), [tableIdentifier]); 60 | 61 | await eventBus.addListener(`${tableName}`); 62 | deepStrictEqual(Object.keys(eventBus.listeners), [tableIdentifier]); 63 | }); 64 | 65 | test("removeListener() throws if called without a table identifier", async function () { 66 | throws( 67 | function () { 68 | // @ts-expect-error intentionally giving wrong number of args 69 | eventBus.removeListener(); 70 | }, 71 | { message: "must provide chainId and tableId to remove a listener" } 72 | ); 73 | }); 74 | 75 | test("removeListener() throws if called with a non-existent table identifier", async function () { 76 | throws( 77 | function () { 78 | eventBus.removeListener({ chainId: 123, tableId: "123" }); 79 | }, 80 | { message: "cannot remove listener that does not exist" } 81 | ); 82 | }); 83 | 84 | test("addListener() can be used to listen for changes to a table", function (done) { 85 | const go = async function (): Promise { 86 | const { meta } = await db 87 | .prepare("CREATE TABLE test_table_subscribe (id integer, name text);") 88 | .run(); 89 | const tableName = meta.txn?.name ?? ""; 90 | await meta.txn?.wait(); 91 | 92 | const bus = await eventBus.addListener(`${tableName}`); 93 | bus.on("change", function (eve: any) { 94 | strictEqual(eve.error, undefined); 95 | match(eve.tableId, /^\d+$/); 96 | strictEqual(typeof eve.blockNumber, "number"); 97 | strictEqual(eve.tableIds instanceof Array, true); 98 | strictEqual(eve.tableIds.length, 1); 99 | match(eve.transactionHash, /^0x[0-9a-f]+$/); 100 | strictEqual(eve.chainId, 31337); 101 | 102 | eventBus.removeAllListeners(); 103 | done(); 104 | }); 105 | 106 | await db 107 | .prepare(`INSERT INTO ${tableName} (id, name) VALUES (1, 'winston');`) 108 | .all(); 109 | }; 110 | go().catch(function (err: Error) { 111 | done(err); 112 | }); 113 | }); 114 | 115 | test("addListener() can be used to listen for transfer of a table", function (done) { 116 | const go = async function (): Promise { 117 | const { meta } = await db 118 | .prepare("CREATE TABLE test_table_transfer (id integer, name text);") 119 | .run(); 120 | const tableName = meta.txn?.name ?? ""; 121 | const tableId = tableName.split("_").pop() ?? ""; 122 | await meta.txn?.wait(); 123 | 124 | const bus = await eventBus.addListener(`${tableName}`); 125 | bus.on("transfer", function (eve: any) { 126 | const from = eve[0]; 127 | const to = eve[1]; 128 | const eventTableId = eve[2].toString(); 129 | const txn = eve[3]; 130 | 131 | match(from, /^0x[0-9a-fA-F]+$/); 132 | match(to, /^0x[0-9a-fA-F]+$/); 133 | strictEqual(tableId, eventTableId); 134 | strictEqual(txn.event, "TransferTable"); 135 | match(txn.transactionHash, /^0x[0-9a-fA-F]+$/); 136 | 137 | eventBus.removeAllListeners(); 138 | done(); 139 | }); 140 | 141 | const registry = new Registry({ signer }); 142 | await registry.safeTransferFrom({ 143 | to: wallet2.address, 144 | tableName: { 145 | chainId: 31337, 146 | tableId, 147 | }, 148 | }); 149 | }; 150 | 151 | go().catch(function (err: Error) { 152 | done(err); 153 | }); 154 | }); 155 | 156 | test("addListener() can be used to listen for setting controller of a table", function (done) { 157 | const go = async function (): Promise { 158 | const { meta } = await db 159 | .prepare("CREATE TABLE test_table_transfer (id integer, name text);") 160 | .run(); 161 | const tableName = meta.txn?.name ?? ""; 162 | const tableId = tableName.split("_").pop() ?? ""; 163 | await meta.txn?.wait(); 164 | 165 | const bus = await eventBus.addListener(`${tableName}`); 166 | bus.on("set-controller", function (eve: any) { 167 | const eventTableId = eve[0].toString(); 168 | const controller = eve[1]; 169 | 170 | strictEqual(tableId, eventTableId); 171 | match(controller, /^0x[0-9a-fA-F]+$/); 172 | 173 | eventBus.removeAllListeners(); 174 | done(); 175 | }); 176 | 177 | const registry = new Registry({ signer }); 178 | await registry.setController({ 179 | controller: wallet2.address, 180 | tableName, 181 | }); 182 | }; 183 | 184 | go().catch(function (err: Error) { 185 | done(err); 186 | }); 187 | }); 188 | 189 | test("addTableIterator() can be used to listen for changes to a table", function (done) { 190 | this.timeout(TEST_TIMEOUT_FACTOR * 30000); 191 | 192 | let tableName: string; 193 | const go = async function (): Promise { 194 | const { meta } = await db 195 | .prepare("CREATE TABLE test_table_subscribe (id integer, name text);") 196 | .run(); 197 | tableName = meta.txn?.name ?? ""; 198 | await meta.txn?.wait(); 199 | 200 | const bus = await eventBus.addTableIterator(`${tableName}`); 201 | 202 | for await (const eve of bus) { 203 | strictEqual((eve as any).error, undefined); 204 | match((eve as any).tableId, /^\d+$/); 205 | strictEqual(typeof (eve as any).blockNumber, "number"); 206 | strictEqual((eve as any).tableIds instanceof Array, true); 207 | strictEqual((eve as any).tableIds.length, 1); 208 | match((eve as any).transactionHash, /^0x[0-9a-f]+$/); 209 | strictEqual((eve as any).chainId, 31337); 210 | 211 | // break after first event and end the test 212 | eventBus.removeAllListeners(); 213 | done(); 214 | } 215 | }; 216 | 217 | // It's a little awkward to use async iterators in a test like this since they 218 | // lock up the function with the `for await`` loop, but this gets the job done 219 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 220 | setTimeout(async function () { 221 | await db 222 | .prepare(`INSERT INTO ${tableName} (id, name) VALUES (1, 'winston');`) 223 | .all(); 224 | }, 5000 * TEST_TIMEOUT_FACTOR); 225 | 226 | go().catch(function (err: Error) { 227 | done(err); 228 | }); 229 | }); 230 | 231 | test("removeAllListeners() removes all listeners and stops listening to the contract", async function () { 232 | const { meta } = await db 233 | .prepare("CREATE TABLE test_table_subscribe (id integer, name text);") 234 | .run(); 235 | const tableName = meta.txn?.name ?? ""; 236 | await meta.txn?.wait(); 237 | 238 | const tableIdentifier = "_" + tableName.split("_").slice(-2).join("_"); 239 | 240 | const eventBus = new TableEventBus(db.config); 241 | deepStrictEqual(eventBus.contracts, {}); 242 | deepStrictEqual(eventBus.listeners, {}); 243 | 244 | await eventBus.addListener(`${tableName}`); 245 | const listeners = eventBus.listeners[tableIdentifier]; 246 | 247 | strictEqual(eventBus.contracts["31337"] instanceof Contract, true); 248 | strictEqual(listeners.chainId, 31337); 249 | strictEqual(listeners.tableId, tableIdentifier.split("_")[2]); 250 | strictEqual(listeners.emitter instanceof EventEmitter, true); 251 | strictEqual(listeners.contractListeners instanceof Array, true); 252 | // 3 is the number of Solidity events we enable listening to 253 | strictEqual(listeners.contractListeners.length, 3); 254 | 255 | eventBus.removeAllListeners(); 256 | deepStrictEqual(eventBus.listeners, {}); 257 | }); 258 | }); 259 | }); 260 | --------------------------------------------------------------------------------