diff --git a/package-lock.json b/package-lock.json index 19cd0457..f76c96a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -184,6 +184,14 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20231025.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20231025.0.tgz", + "integrity": "sha512-TkcZkntUTOcvJ4vgmwpNfLTclpMbmbClZCe62B25/VTukmyv91joRa4eKzSjzCZUXTbFHNmVdOpmGaaJU2U3+A==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -4075,9 +4083,9 @@ } }, "node_modules/d1-orm": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/d1-orm/-/d1-orm-0.7.2.tgz", - "integrity": "sha512-s0s/RudLe7/BspbUf7ZwKe+0Eov5fFt0WVPVWdphx6kAWDMj0OTy9KaWockTOMVoI4XPGypiZ/wxK0kb+KVmSg==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/d1-orm/-/d1-orm-0.9.0.tgz", + "integrity": "sha512-avgEvxttrcpL/+vupernkH3CgT6rI5VI07XT5HwhD4x99fHs3WZ2SldnmTsduB701+dAuc4BEi4eQWkaPWRZKQ==", "dev": true }, "node_modules/dargs": { @@ -4477,6 +4485,92 @@ "node": ">=10" } }, + "node_modules/drizzle-orm": { + "version": "0.28.6", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.28.6.tgz", + "integrity": "sha512-yBe+F9htrlYER7uXgDJUQsTHFoIrI5yMm5A0bg0GiZ/kY5jNXTWoEy4KQtg35cE27sw1VbgzoMWHAgCckUUUww==", + "dev": true, + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=3", + "@libsql/client": "*", + "@neondatabase/serverless": ">=0.1", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@vercel/postgres": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -12964,7 +13058,8 @@ "@databases/sql": "^3.2.0", "@ethersproject/experimental": "^5.7.0", "@playwright/test": "^1.30.0", - "d1-orm": "^0.7.1", + "d1-orm": "^0.9.0", + "drizzle-orm": "^0.28.6", "openapi-typescript": "6.2.4", "typedoc": "^0.25.0" } @@ -13085,6 +13180,14 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@cloudflare/workers-types": { + "version": "4.20231025.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20231025.0.tgz", + "integrity": "sha512-TkcZkntUTOcvJ4vgmwpNfLTclpMbmbClZCe62B25/VTukmyv91joRa4eKzSjzCZUXTbFHNmVdOpmGaaJU2U3+A==", + "dev": true, + "optional": true, + "peer": true + }, "@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -14571,9 +14674,10 @@ "@databases/sql": "^3.2.0", "@ethersproject/experimental": "^5.7.0", "@playwright/test": "^1.30.0", - "@tableland/evm": "4.4.0", + "@tableland/evm": "^4.4.0", "@tableland/sqlparser": "^1.3.0", - "d1-orm": "^0.7.1", + "d1-orm": "^0.9.0", + "drizzle-orm": "^0.28.6", "ethers": "^5.7.2", "openapi-typescript": "6.2.4", "typedoc": "^0.25.0" @@ -16087,9 +16191,9 @@ } }, "d1-orm": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/d1-orm/-/d1-orm-0.7.2.tgz", - "integrity": "sha512-s0s/RudLe7/BspbUf7ZwKe+0Eov5fFt0WVPVWdphx6kAWDMj0OTy9KaWockTOMVoI4XPGypiZ/wxK0kb+KVmSg==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/d1-orm/-/d1-orm-0.9.0.tgz", + "integrity": "sha512-avgEvxttrcpL/+vupernkH3CgT6rI5VI07XT5HwhD4x99fHs3WZ2SldnmTsduB701+dAuc4BEi4eQWkaPWRZKQ==", "dev": true }, "dargs": { @@ -16359,6 +16463,13 @@ "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", "dev": true }, + "drizzle-orm": { + "version": "0.28.6", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.28.6.tgz", + "integrity": "sha512-yBe+F9htrlYER7uXgDJUQsTHFoIrI5yMm5A0bg0GiZ/kY5jNXTWoEy4KQtg35cE27sw1VbgzoMWHAgCckUUUww==", + "dev": true, + "requires": {} + }, "duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", diff --git a/packages/local/test/e2e.test.ts b/packages/local/test/e2e.test.ts index 35f831e3..3daba20b 100644 --- a/packages/local/test/e2e.test.ts +++ b/packages/local/test/e2e.test.ts @@ -37,7 +37,7 @@ describe("network end to end", function () { ); const data = await db - .prepare(`SELECT * FROM ${res.meta.txn?.name as string};`) + .prepare(`SELECT * FROM ${res.txn?.name as string};`) .all(); expect(data.results).to.eql([]); }); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 95d565cc..54d84c92 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -85,7 +85,8 @@ "@databases/sql": "^3.2.0", "@ethersproject/experimental": "^5.7.0", "@playwright/test": "^1.30.0", - "d1-orm": "^0.7.1", + "d1-orm": "^0.9.0", + "drizzle-orm": "^0.28.6", "openapi-typescript": "6.2.4", "typedoc": "^0.25.0" }, diff --git a/packages/sdk/src/database.ts b/packages/sdk/src/database.ts index 21ab93f5..524be4f7 100644 --- a/packages/sdk/src/database.ts +++ b/packages/sdk/src/database.ts @@ -1,6 +1,10 @@ import { type NormalizedStatement } from "@tableland/sqlparser"; -import { type Result, type Runnable } from "./registry/index.js"; -import { wrapResult } from "./registry/utils.js"; +import { + type Result, + type ExecResult, + type Runnable, +} from "./registry/index.js"; +import { wrapResult, wrapExecResult } from "./registry/utils.js"; import { type Config, type AutoWaitConfig, @@ -194,16 +198,14 @@ export class Database { async exec( statementStrings: string, controller?: PollingController - ): Promise> { + ): Promise> { // TODO: Note that this method appears to be the wrong return type in practice. try { const { statements } = await normalize(statementStrings); const count = statements.length; const statement = this.prepare(statementStrings); - const result = await statement.run({ controller }); - // Adds a count property which isn't typed - result.meta.count = count; - return result; + const result = await statement.run({ controller }); + return wrapExecResult(result, count); } catch (cause: any) { if (cause.message.startsWith("RUN_ERROR") === true) { throw errorWithCause("EXEC_ERROR", cause.cause); diff --git a/packages/sdk/src/helpers/await.ts b/packages/sdk/src/helpers/await.ts index 4fdeac3d..e8b071db 100644 --- a/packages/sdk/src/helpers/await.ts +++ b/packages/sdk/src/helpers/await.ts @@ -150,3 +150,19 @@ export async function getAsyncPoller( }; return await new Promise(checkCondition); } + +/** + * Check if an argument is a valid {@link PollingController}. + * @param arg The argument to check. + * @returns An assertion/boolean, indicating if the argument is valid. + */ +export function isPollingController(arg: any): arg is PollingController { + return ( + arg != null && + arg.signal instanceof AbortSignal && + typeof arg.abort === "function" && + typeof arg.interval === "number" && + typeof arg.cancel === "function" && + typeof arg.timeout === "number" + ); +} diff --git a/packages/sdk/src/registry/index.ts b/packages/sdk/src/registry/index.ts index 206b8dd3..0e72c9f4 100644 --- a/packages/sdk/src/registry/index.ts +++ b/packages/sdk/src/registry/index.ts @@ -29,6 +29,7 @@ import { export { type Result, + type ExecResult, type Metadata, type WaitableTransactionReceipt, type Named, diff --git a/packages/sdk/src/registry/utils.ts b/packages/sdk/src/registry/utils.ts index 1c3f7cf2..0b8f3d25 100644 --- a/packages/sdk/src/registry/utils.ts +++ b/packages/sdk/src/registry/utils.ts @@ -86,6 +86,12 @@ function isTransactionReceipt(arg: any): arg is WaitableTransactionReceipt { ); } +/** + * Wrap results for a Statement `run` or `all` call, or a Database `batch` call. + * @param resultsOrReceipt Either query results or the transaction receipt. + * @param duration Total client-side duration of the async call. + * @returns Wrapped results with metadata. + */ export function wrapResult( resultsOrReceipt: T[] | WaitableTransactionReceipt, duration: number @@ -95,6 +101,7 @@ export function wrapResult( meta, success: true, results: [], + error: undefined, }; if (isTransactionReceipt(resultsOrReceipt)) { return { ...result, meta: { ...meta, txn: resultsOrReceipt } }; @@ -102,6 +109,28 @@ export function wrapResult( return { ...result, results: resultsOrReceipt }; } +/** + * Wrap results for a Database `exec` call. + * @param result The result of the a {@link wrapResult} call, made by `exec` under the hood. + * @param count The count of executed statements. + * @returns Wrapped {@link ExecResult} with metadata and transaction + * receipt or query results. + */ +export function wrapExecResult( + result: Result, + count: number +): ExecResult { + const { duration } = result.meta; + const execResult: ExecResult = { + count, + duration, + }; + if (result.meta.txn != null) { + return { ...execResult, txn: result.meta.txn }; + } + return { ...execResult, results: result.results }; +} + /** * Metadata represents meta information about an executed statement/transaction. */ @@ -111,11 +140,11 @@ export interface Metadata { */ duration: number; /** - * The optional transactionn information receipt. + * The optional transaction information receipt. */ txn?: WaitableTransactionReceipt; /** - * Metadata may contrain additional arbitrary key/values pairs. + * Metadata may constrain additional arbitrary key/values pairs. */ [key: string]: any; } @@ -131,17 +160,34 @@ export interface Result { /** * Whether the query or transaction was successful. */ - success: boolean; // almost always true + success: true; // TODO: this is a bug in D1, but if we want to be compatible + // we have to type it like this :< + // https://github.com/cloudflare/workerd/issues/940 /** * If there was an error, this will contain the error string. */ - error?: string; + error: undefined; /** * Additional meta information. */ meta: Metadata; } +/** + * ExecResult represents the return result for executed Database statements via `exec()`. + */ +export interface ExecResult + extends Pick { + /** + * The count of executed statements. + */ + count: number; + /** + * The optional list of query results. + */ + results?: T[]; +} + export async function extractReadonly( conn: Config, { tables, type }: Omit diff --git a/packages/sdk/src/statement.ts b/packages/sdk/src/statement.ts index 0716dc00..9c6b2577 100644 --- a/packages/sdk/src/statement.ts +++ b/packages/sdk/src/statement.ts @@ -12,6 +12,7 @@ import { checkWait, normalize, } from "./helpers/index.js"; +import { isPollingController } from "./helpers/await.js"; import { type ExtractedStatement, type Result, @@ -153,7 +154,7 @@ export class Statement { * Executes a query and returns all rows and metadata. * @param opts An optional object used to control behavior, see {@link Options} */ - async all(opts: Options = {}): Promise> { + async all>(opts: Options = {}): Promise> { try { const start = performance.now(); const { sql, type, tables } = await this.#parseAndExtract(); @@ -179,6 +180,11 @@ export class Statement { } } + // Check if the first param seen by `first()` is an Options object + #checkIsValidOpts(opts: any): opts is Options { + return opts != null && isPollingController(opts.controller); + } + /** * Executes a query and returns the first row of the results. * This does not return metadata like the other methods. @@ -187,22 +193,26 @@ export class Statement { * @param colName If provided, filter results to the provided column. * @param opts An optional object used to control behavior, see {@link Options} */ + async first>(opts?: Options): Promise; // eslint-disable-next-line @typescript-eslint/no-unused-vars - async first(opts?: Options): Promise; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async first( + async first( colName: undefined, opts?: Options ): Promise; - async first( + async first( colName: K, opts?: Options ): Promise; - async first( - colName?: K, + async first( + colNameOrOpts?: K, opts: Options = {} ): Promise { try { + // Handle first overload to ensure passed `opts` are not set to `colName` + const colNameIsOpts = this.#checkIsValidOpts(colNameOrOpts); + const { colName, options } = colNameIsOpts + ? { colName: undefined, options: colNameOrOpts } + : { colName: colNameOrOpts, options: opts }; const { sql, type, tables } = await this.#parseAndExtract(); switch (type) { case "read": { @@ -210,7 +220,7 @@ export class Statement { type, tables, }); - const results = await queryFirst(config, sql, opts.controller); + const results = await queryFirst(config, sql, options.controller); if (results == null || colName == null) { return results; } @@ -223,7 +233,7 @@ export class Statement { sql, tables, }, - opts.controller + options.controller ); return null; } @@ -238,10 +248,10 @@ export class Statement { * Runs the query/queries, but returns no results. Instead, run() * returns the metrics only. Useful for write operations like * UPDATE, DELETE or INSERT. - * @param controller An optional object used to control behavior, see {@link Options} + * @param opts An optional object used to control behavior, see {@link Options} * @returns A results object with metadata only (results are null or an empty array). */ - async run(opts: Options = {}): Promise> { + async run>(opts: Options = {}): Promise> { try { const start = performance.now(); const { sql, type, tables } = await this.#parseAndExtract(); @@ -269,7 +279,7 @@ export class Statement { /** * Same as stmt.all(), but returns an array of rows instead of objects. - * @param controller An optional object used to control behavior, see {@link Options} + * @param opts An optional object used to control behavior, see {@link Options} * @returns An array of raw query results. */ async raw(opts: Options = {}): Promise>> { diff --git a/packages/sdk/test/chains.test.ts b/packages/sdk/test/chains.test.ts index f199dc24..a6f625f1 100644 --- a/packages/sdk/test/chains.test.ts +++ b/packages/sdk/test/chains.test.ts @@ -204,6 +204,66 @@ describe("chains", function () { }); }); + describe("getChainPollingController()", function () { + test("where we get polling controller with chain ids", async function () { + const homesteadController = getChainPollingController(1); // mainnet + const filecoinController = getChainPollingController(314); // filecoin + const maticmumController = getChainPollingController(80001); // polygon mumbai + const filecoinTestnetController = getChainPollingController(314159); // filecoin testnet + const localController = getChainPollingController(31337); // local + strictEqual(homesteadController.interval, 1500); // most should use the same 1500ms interval + strictEqual(homesteadController.timeout, 40000); // but different timeouts + strictEqual(filecoinController.interval, 5000); // filecoin has longer intervals + strictEqual(filecoinController.timeout, 210000); + strictEqual(maticmumController.interval, 1500); + strictEqual(maticmumController.timeout, 15000); + strictEqual(filecoinTestnetController.interval, 5000); + strictEqual(filecoinTestnetController.timeout, 210000); + strictEqual(localController.interval, 1500); + strictEqual(localController.timeout, 5000); + homesteadController.cancel(); + filecoinController.cancel(); + maticmumController.cancel(); + filecoinTestnetController.cancel(); + localController.cancel(); + }); + + test("where we get polling controller with chain names", async function () { + const homesteadController = getChainPollingController("homestead"); + const filecoinTestnetController = getChainPollingController( + "filecoin-calibration" + ); + const localController = getChainPollingController("local-tableland"); + strictEqual(homesteadController.interval, 1500); + strictEqual(homesteadController.timeout, 40000); + strictEqual(filecoinTestnetController.interval, 5000); + strictEqual(filecoinTestnetController.timeout, 210000); + strictEqual(localController.interval, 1500); + strictEqual(localController.timeout, 5000); + homesteadController.cancel(); + filecoinTestnetController.cancel(); + localController.cancel(); + }); + + test("when called with invalid chain name or id", async function () { + throws( + // @ts-expect-error need to tell ts to ignore this since we are testing a failure when used without ts + () => getChainPollingController("invalid"), + (err: any) => { + strictEqual(err.message, "cannot use unsupported chain: invalid"); + return true; + } + ); + throws( + () => getChainPollingController(99999), + (err: any) => { + strictEqual(err.message, "cannot use unsupported chain: 99999"); + return true; + } + ); + }); + }); + describe("overrideDefaults()", function () { test("when called incorrectly", async function () { throws( diff --git a/packages/sdk/test/database.test.ts b/packages/sdk/test/database.test.ts index cc62f713..57c5e8cd 100644 --- a/packages/sdk/test/database.test.ts +++ b/packages/sdk/test/database.test.ts @@ -599,12 +599,12 @@ describe("database", function () { test("when executing mutations works and adds rows", async function () { const sql = `INSERT INTO ${tableName} (name, age) VALUES ('Bobby', 5); INSERT INTO ${tableName} (name, age) VALUES ('Tables', 6);`; - const { meta } = await db.exec(sql); - assert(meta.duration != null); - strictEqual(meta.count, 2); - assert(meta.txn != null); + const { duration, count, txn } = await db.exec(sql); + assert(duration != null); + strictEqual(count, 2); + assert(txn != null); - await meta.txn.wait(); + await txn.wait(); const results = await db.prepare("SELECT * FROM " + tableName).all(); strictEqual(results.results.length, 2); @@ -625,10 +625,10 @@ describe("database", function () { }); test("when querying a table right after creation", async function () { - const { meta } = await db.exec( + const { txn } = await db.exec( "CREATE TABLE exec_nowait (keyy TEXT, vall TEXT);" ); - const tableName = meta.txn?.name ?? ""; + const tableName = txn?.name ?? ""; match(tableName, /^exec_nowait_31337_\d+$/); const { results } = await db.exec(`SELECT * FROM ${tableName};`); @@ -664,10 +664,10 @@ describe("database", function () { await meta.txn?.wait(); } const sql = `SELECT name, age FROM ${tableName} WHERE name='Bobby';`; - const { meta } = await db.exec(sql); - strictEqual(meta.count, 1); - assert(meta.duration != null); - strictEqual(meta.txn, undefined); + const { count, duration, txn } = await db.exec(sql); + strictEqual(count, 1); + assert(duration != null); + strictEqual(txn, undefined); }); }); diff --git a/packages/sdk/test/statement.test.ts b/packages/sdk/test/statement.test.ts index bee41b5b..088ff15f 100644 --- a/packages/sdk/test/statement.test.ts +++ b/packages/sdk/test/statement.test.ts @@ -286,6 +286,29 @@ CREATE TABLE test_run (counter blurg); await meta.txn?.wait(); }); + + describe("with options", function () { + test("when using an abort controller to halt a query", async function () { + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const controller = createPollingController(); + controller.abort(); + await rejects(stmt.run({ controller }), (err: any) => { + match(err.cause.message, /Th(e|is) operation was aborted/); + return true; + }); + }); + + test("when using an abort controller and select statement is valid", async function () { + const controller = createPollingController(); + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const { results, meta, error } = await stmt.run<{ counter: number }>({ + controller, + }); + strictEqual(error, undefined); + assert(meta.duration != null); + assert(results.length > 0); + }); + }); }); describe(".all()", function () { @@ -350,18 +373,6 @@ SELECT * FROM 3.14; ]); }); - test("when using an abort controller to halt a query", async function () { - const stmt = db - .prepare(`SELECT name, age FROM ${tableName} WHERE name=?`) - .bind("Bobby"); - const controller = createPollingController(); - controller.abort(); - await rejects(stmt.all({ controller }), (err: any) => { - match(err.cause.message, /Th(e|is) operation was aborted/); - return true; - }); - }); - test("when trying to extract a missing column", async function () { const sql = `SELECT missing FROM ${tableName};`; @@ -493,6 +504,34 @@ SELECT * FROM 3.14; db.config.autoWait = false; }); }); + + describe("with options", function () { + test("when using an abort controller to halt a query", async function () { + const stmt = db + .prepare(`SELECT name, age FROM ${tableName} WHERE name=?`) + .bind("Bobby"); + const controller = createPollingController(); + controller.abort(); + await rejects(stmt.all({ controller }), (err: any) => { + match(err.cause.message, /Th(e|is) operation was aborted/); + return true; + }); + }); + + test("when using an abort controller and select statement is valid", async function () { + const controller = createPollingController(); + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const { results, meta, error } = await stmt.all<{ + counter: number; + info: string; + }>({ + controller, + }); + strictEqual(error, undefined); + assert(meta.duration != null); + assert(results.length > 0); + }); + }); }); describe(".first()", function () { @@ -564,6 +603,20 @@ SELECT * FROM 3.14; strictEqual(row, null); }); + test("when select statement with colName of undefined is valid", async function () { + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const row = await stmt.first<{ counter: number; info: string }>( + undefined + ); + deepStrictEqual(row, { counter: 1, info: "one" }); + }); + + test("when select statement with colName of string is valid", async function () { + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const row = await stmt.first<{ counter: number }>("counter"); + deepStrictEqual(row, [1]); + }); + describe("with autoWait turned on", function () { this.beforeAll(() => { db.config.autoWait = true; @@ -577,6 +630,74 @@ SELECT * FROM 3.14; db.config.autoWait = false; }); }); + + describe("with options", function () { + test("when using an abort controller to halt a query with no colName param passed", async function () { + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const controller = createPollingController(); + controller.abort(); + await rejects( + stmt.first<{ counter: number; info: string }>({ controller }), + (err: any) => { + match(err.cause.message, /Th(e|is) operation was aborted/); + return true; + } + ); + }); + + test("when using an abort controller to halt a query with colName as undefined", async function () { + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const controller = createPollingController(); + controller.abort(); + await rejects(stmt.first(undefined, { controller }), (err: any) => { + match(err.cause.message, /Th(e|is) operation was aborted/); + return true; + }); + }); + + test("when using an abort controller to halt a query with colName as string", async function () { + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const controller = createPollingController(); + controller.abort(); + await rejects( + stmt.first<{ counter: number }>("counter", { controller }), + (err: any) => { + match(err.cause.message, /Th(e|is) operation was aborted/); + return true; + } + ); + }); + + test("when using an abort controller and select with no colName param passed is valid", async function () { + const controller = createPollingController(); + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const row = await stmt.first<{ counter: number; info: string }>({ + controller, + }); + deepStrictEqual(row, { counter: 1, info: "one" }); + }); + + test("when using an abort controller and select with colName of undefined is valid", async function () { + const controller = createPollingController(); + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const row = await stmt.first<{ counter: number; info: string }>( + undefined, + { + controller, + } + ); + deepStrictEqual(row, { counter: 1, info: "one" }); + }); + + test("when using an abort controller and select with colName of string is valid", async function () { + const controller = createPollingController(); + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const row = await stmt.first<{ counter: number }>("counter", { + controller, + }); + deepStrictEqual(row, [1]); + }); + }); }); describe(".raw()", function () { @@ -665,6 +786,31 @@ SELECT * FROM 3.14; db.config.autoWait = false; }); }); + + describe("with options", function () { + test("when using an abort controller to halt a query", async function () { + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const controller = createPollingController(); + controller.abort(); + await rejects(stmt.raw({ controller }), (err: any) => { + match(err.cause.message, /Th(e|is) operation was aborted/); + return true; + }); + }); + + test("when using an abort controller and select statement is valid", async function () { + const controller = createPollingController(); + const stmt = db.prepare(`SELECT * FROM ${tableName};`); + const results = await stmt.raw<{ + id: number; + counter: number; + info: string; + }>({ + controller, + }); + assert(results.length > 0); + }); + }); }); describe(".bind()", function () { diff --git a/packages/sdk/test/thirdparty.test.ts b/packages/sdk/test/thirdparty.test.ts index 3c2a9804..d8187cbf 100644 --- a/packages/sdk/test/thirdparty.test.ts +++ b/packages/sdk/test/thirdparty.test.ts @@ -12,9 +12,17 @@ import { } from "d1-orm"; import sql, { type FormatConfig } from "@databases/sql"; import { escapeSQLiteIdentifier } from "@databases/escape-identifier"; +import { + type InferInsertModel, + type InferSelectModel, + sql as drizzleSql, +} from "drizzle-orm"; +import { drizzle } from "drizzle-orm/d1"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { NonceManager } from "@ethersproject/experimental"; import { getDefaultProvider } from "../src/helpers/index.js"; import { Database } from "../src/index.js"; +import { type NameMapping } from "../src/helpers/index.js"; import { TEST_TIMEOUT_FACTOR, TEST_PROVIDER_URL } from "./setup"; describe("thirdparty", function () { @@ -27,7 +35,23 @@ describe("thirdparty", function () { const baseSigner = wallet.connect(provider); // Also demonstrates the nonce manager usage const signer = new NonceManager(baseSigner); - const db = new Database({ signer }); + // an `aliases` option is required for Drizzle ORM usage since D1 uses `exec` + // internally to do table creation, and we need to capture the uu table name + const nameMap: NameMapping = {}; + const db = new Database({ + signer, + // this parameter is the core of the aliases feature + aliases: { + read: async function () { + return nameMap; + }, + write: async function (names) { + for (const uuTableName in names) { + nameMap[uuTableName] = names[uuTableName]; + } + }, + }, + }); describe("d1-orm", function () { const orm = new D1Orm(db); @@ -39,6 +63,7 @@ describe("thirdparty", function () { tableName: "users", primaryKeys: "id", uniqueKeys: [["email"]], + withRowId: true, // If this is not set, `WITHOUT ROWID` will be attached and fail }, { id: { @@ -59,13 +84,9 @@ describe("thirdparty", function () { type User = Infer; this.beforeAll(async function () { - const create = await users.CreateTable({ + await users.CreateTable({ strategy: "default", }); - await create.meta.txn.wait(); - - // TODO: Find a nicer way to deal with this... - (users.tableName as any) = create.meta.txn.name; }); test("where a basic model is used to create data", async function () { @@ -231,4 +252,58 @@ describe("thirdparty", function () { ]); }); }); + + describe("drizzle-orm", function () { + // See https://orm.drizzle.team/docs/quick-sqlite/d1 + const user = sqliteTable("user", { + id: integer("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + }); + type User = InferSelectModel; + type NewUser = InferInsertModel; + + const database = drizzle(db, { schema: { user } }); + + this.beforeAll(async function () { + // Opt for SDK table create over Drizzle migration process for simplicity + const { meta } = await db + .prepare( + "CREATE TABLE user (id integer primary key, name text not null, email text not null unique);" + ) + .run(); + await meta.txn?.wait(); + }); + + test("where a basic insert and read statements work", async function () { + const values: NewUser = { + id: 1, + name: "John Doe", + email: "john-doe@gmail.com", + }; + const { meta } = await database.insert(user).values(values).run(); + await meta.txn?.wait(); + strictEqual(typeof meta.txn?.transactionHash, "string"); + strictEqual(meta.txn?.transactionHash.length, 66); + strictEqual(meta.duration > 0, true); + + const results: User[] = await database.select().from(user); + deepStrictEqual(results, [values]); + }); + + test("where a templating & run() works for update and read", async function () { + const writeSql = drizzleSql`update user set id = 2, name = 'Jane Doe', email = 'jane-doe@gmail.com' where id = 1`; + const { meta } = await database.run(writeSql); + await meta.txn?.wait(); + strictEqual(typeof meta.txn?.transactionHash, "string"); + strictEqual(meta.txn?.transactionHash.length, 66); + strictEqual(meta.duration > 0, true); + + const readSql = drizzleSql`select * from user where id = 2`; + const { results } = await database.run(readSql); + deepStrictEqual(results, [ + { id: 2, name: "Jane Doe", email: "jane-doe@gmail.com" }, + ]); + }); + }); });