UNPKG

@worker-tools/deno-kv-storage

Version:

An implementation of the StorageArea (1,2,3) interface for Deno with an extensible system for supporting various database backends.

797 lines (745 loc) 25.9 kB
import type { QueryClient } from "../client.js"; import { Query, QueryArguments, QueryArrayResult, QueryObjectOptions, QueryObjectResult, QueryOptions, QueryResult, ResultType, templateStringToQuery, } from "./query.js"; import { isTemplateString } from "../utils/utils.js"; import { PostgresError, TransactionError } from "../client/error.js"; export class Savepoint { /** * This is the count of the current savepoint instances in the transaction */ #instance_count = 0; #release_callback: (name: string) => Promise<void>; #update_callback: (name: string) => Promise<void>; constructor( public readonly name: string, update_callback: (name: string) => Promise<void>, release_callback: (name: string) => Promise<void>, ) { this.#release_callback = release_callback; this.#update_callback = update_callback; } get instances() { return this.#instance_count; } /** * Releasing a savepoint will remove it's last instance in the transaction * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * const savepoint = await transaction.savepoint("n1"); * await savepoint.release(); * transaction.rollback(savepoint); // Error, can't rollback because the savepoint was released * ``` * * It will also allow you to set the savepoint to the position it had before the last update * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * const savepoint = await transaction.savepoint("n1"); * await savepoint.update(); * await savepoint.release(); // This drops the update of the last statement * transaction.rollback(savepoint); // Will rollback to the first instance of the savepoint * ``` * * This function will throw if there are no savepoint instances to drop */ async release() { if (this.#instance_count === 0) { throw new Error("This savepoint has no instances to release"); } await this.#release_callback(this.name); --this.#instance_count; } /** * Updating a savepoint will update its position in the transaction execution * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * const my_value = "some value"; * * const savepoint = await transaction.savepoint("n1"); * transaction.queryArray`INSERT INTO MY_TABLE (X) VALUES (${my_value})`; * await savepoint.update(); // Rolling back will now return you to this point on the transaction * ``` * * You can also undo a savepoint update by using the `release` method * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * const savepoint = await transaction.savepoint("n1"); * transaction.queryArray`DELETE FROM VERY_IMPORTANT_TABLE`; * await savepoint.update(); // Oops, shouldn't have updated the savepoint * await savepoint.release(); // This will undo the last update and return the savepoint to the first instance * await transaction.rollback(); // Will rollback before the table was deleted * ``` */ async update() { await this.#update_callback(this.name); ++this.#instance_count; } } type IsolationLevel = "read_committed" | "repeatable_read" | "serializable"; export type TransactionOptions = { isolation_level?: IsolationLevel; read_only?: boolean; snapshot?: string; }; export class Transaction { #client: QueryClient; #executeQuery: (query: Query<ResultType>) => Promise<QueryResult>; #isolation_level: IsolationLevel; #read_only: boolean; #savepoints: Savepoint[] = []; #snapshot?: string; #updateClientLock: (name: string | null) => void; constructor( public name: string, options: TransactionOptions | undefined, client: QueryClient, execute_query_callback: (query: Query<ResultType>) => Promise<QueryResult>, update_client_lock_callback: (name: string | null) => void, ) { this.#client = client; this.#executeQuery = execute_query_callback; this.#isolation_level = options?.isolation_level ?? "read_committed"; this.#read_only = options?.read_only ?? false; this.#snapshot = options?.snapshot; this.#updateClientLock = update_client_lock_callback; } get isolation_level() { return this.#isolation_level; } get savepoints() { return this.#savepoints; } /** * This method will throw if the transaction opened in the client doesn't match this one */ #assertTransactionOpen() { if (this.#client.session.current_transaction !== this.name) { throw new Error( `This transaction has not been started yet, make sure to use the "begin" method to do so`, ); } } #resetTransaction() { this.#savepoints = []; } /** * The begin method will officially begin the transaction, and it must be called before * any query or transaction operation is executed in order to lock the session * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction_name"); * * await transaction.begin(); // Session is locked, transaction operations are now safe * // Important operations * await transaction.commit(); // Session is unlocked, external operations can now take place * ``` * https://www.postgresql.org/docs/14/sql-begin.html */ async begin() { if (this.#client.session.current_transaction !== null) { if (this.#client.session.current_transaction === this.name) { throw new Error( "This transaction is already open", ); } throw new Error( `This client already has an ongoing transaction "${this.#client.session.current_transaction}"`, ); } let isolation_level; switch (this.#isolation_level) { case "read_committed": { isolation_level = "READ COMMITTED"; break; } case "repeatable_read": { isolation_level = "REPEATABLE READ"; break; } case "serializable": { isolation_level = "SERIALIZABLE"; break; } default: throw new Error( `Unexpected isolation level "${this.#isolation_level}"`, ); } let permissions; if (this.#read_only) { permissions = "READ ONLY"; } else { permissions = "READ WRITE"; } let snapshot = ""; if (this.#snapshot) { snapshot = `SET TRANSACTION SNAPSHOT '${this.#snapshot}'`; } try { await this.#client.queryArray( `BEGIN ${permissions} ISOLATION LEVEL ${isolation_level};${snapshot}`, ); } catch (e) { if (e instanceof PostgresError) { throw new TransactionError(this.name, e); } else { throw e; } } this.#updateClientLock(this.name); } /** * The commit method will make permanent all changes made to the database in the * current transaction and end the current transaction * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * await transaction.begin(); * // Important operations * await transaction.commit(); // Will terminate the transaction and save all changes * ``` * * The commit method allows you to specify a "chain" option, that allows you to both commit the current changes and * start a new with the same transaction parameters in a single statement * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * // Transaction operations I want to commit * await transaction.commit({ chain: true }); // All changes are saved, following statements will be executed inside a transaction * await transaction.queryArray`DELETE SOMETHING FROM SOMEWHERE`; // Still inside the transaction * await transaction.commit(); // The transaction finishes for good * ``` * * https://www.postgresql.org/docs/14/sql-commit.html */ async commit(options?: { chain?: boolean }) { this.#assertTransactionOpen(); const chain = options?.chain ?? false; try { await this.queryArray(`COMMIT ${chain ? "AND CHAIN" : ""}`); } catch (e) { if (e instanceof PostgresError) { throw new TransactionError(this.name, e); } else { throw e; } } this.#resetTransaction(); if (!chain) { this.#updateClientLock(null); } } /** * This method will search for the provided savepoint name and return a * reference to the requested savepoint, otherwise it will return undefined */ getSavepoint(name: string): Savepoint | undefined { return this.#savepoints.find((sv) => sv.name === name.toLowerCase()); } /** * This method will list you all of the active savepoints in this transaction */ getSavepoints(): string[] { return this.#savepoints .filter(({ instances }) => instances > 0) .map(({ name }) => name); } /** * This method returns the snapshot id of the on going transaction, allowing you to share * the snapshot state between two transactions * * ```ts * import { Client } from "../client.ts"; * * const client_1 = new Client(); * const client_2 = new Client(); * const transaction_1 = client_1.createTransaction("transaction"); * * const snapshot = await transaction_1.getSnapshot(); * const transaction_2 = client_2.createTransaction("new_transaction", { isolation_level: "repeatable_read", snapshot }); * // transaction_2 now shares the same starting state that transaction_1 had * ``` * https://www.postgresql.org/docs/14/functions-admin.html#FUNCTIONS-SNAPSHOT-SYNCHRONIZATION */ async getSnapshot(): Promise<string> { this.#assertTransactionOpen(); const { rows } = await this.queryObject<{ snapshot: string }> `SELECT PG_EXPORT_SNAPSHOT() AS SNAPSHOT;`; return rows[0].snapshot; } /** * This method allows executed queries to be retrieved as array entries. * It supports a generic interface in order to type the entries retrieved by the query * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * const {rows} = await transaction.queryArray( * "SELECT ID, NAME FROM CLIENTS" * ); // Array<unknown[]> * ``` * * You can pass type arguments to the query in order to hint TypeScript what the return value will be * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * const { rows } = await transaction.queryArray<[number, string]>( * "SELECT ID, NAME FROM CLIENTS" * ); // Array<[number, string]> * ``` * * It also allows you to execute prepared stamements with template strings * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * const id = 12; * // Array<[number, string]> * const { rows } = await transaction.queryArray<[number, string]>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`; * ``` */ async queryArray<T extends Array<unknown>>( query: string, args?: QueryArguments, ): Promise<QueryArrayResult<T>>; async queryArray<T extends Array<unknown>>( config: QueryOptions, ): Promise<QueryArrayResult<T>>; async queryArray<T extends Array<unknown>>( strings: TemplateStringsArray, ...args: unknown[] ): Promise<QueryArrayResult<T>>; async queryArray<T extends Array<unknown> = Array<unknown>>( query_template_or_config: TemplateStringsArray | string | QueryOptions, ...args: unknown[] | [QueryArguments | undefined] ): Promise<QueryArrayResult<T>> { this.#assertTransactionOpen(); let query: Query<ResultType.ARRAY>; if (typeof query_template_or_config === "string") { query = new Query( query_template_or_config, ResultType.ARRAY, args as QueryArguments | undefined, ); } else if (isTemplateString(query_template_or_config)) { query = templateStringToQuery( query_template_or_config, args, ResultType.ARRAY, ); } else { query = new Query(query_template_or_config, ResultType.ARRAY); } try { return await this.#executeQuery(query) as QueryArrayResult<T>; } catch (e) { if (e instanceof PostgresError) { await this.commit(); throw new TransactionError(this.name, e); } else { throw e; } } } /** * This method allows executed queries to be retrieved as object entries. * It supports a generic interface in order to type the entries retrieved by the query * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * { * const { rows } = await transaction.queryObject( * "SELECT ID, NAME FROM CLIENTS" * ); // Record<string, unknown> * } * * { * const { rows } = await transaction.queryObject<{id: number, name: string}>( * "SELECT ID, NAME FROM CLIENTS" * ); // Array<{id: number, name: string}> * } * ``` * * You can also map the expected results to object fields using the configuration interface. * This will be assigned in the order they were provided * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * { * const { rows } = await transaction.queryObject( * "SELECT ID, NAME FROM CLIENTS" * ); * * console.log(rows); // [{id: 78, name: "Frank"}, {id: 15, name: "Sarah"}] * } * * { * const { rows } = await transaction.queryObject({ * text: "SELECT ID, NAME FROM CLIENTS", * fields: ["personal_id", "complete_name"], * }); * * console.log(rows); // [{personal_id: 78, complete_name: "Frank"}, {personal_id: 15, complete_name: "Sarah"}] * } * ``` * * It also allows you to execute prepared stamements with template strings * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * const id = 12; * // Array<{id: number, name: string}> * const {rows} = await transaction.queryObject<{id: number, name: string}>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`; * ``` */ async queryObject<T>( query: string, args?: QueryArguments, ): Promise<QueryObjectResult<T>>; async queryObject<T>( config: QueryObjectOptions, ): Promise<QueryObjectResult<T>>; async queryObject<T>( query: TemplateStringsArray, ...args: unknown[] ): Promise<QueryObjectResult<T>>; async queryObject< T = Record<string, unknown>, >( query_template_or_config: | string | QueryObjectOptions | TemplateStringsArray, ...args: unknown[] | [QueryArguments | undefined] ): Promise<QueryObjectResult<T>> { this.#assertTransactionOpen(); let query: Query<ResultType.OBJECT>; if (typeof query_template_or_config === "string") { query = new Query( query_template_or_config, ResultType.OBJECT, args[0] as QueryArguments | undefined, ); } else if (isTemplateString(query_template_or_config)) { query = templateStringToQuery( query_template_or_config, args, ResultType.OBJECT, ); } else { query = new Query( query_template_or_config as QueryObjectOptions, ResultType.OBJECT, ); } try { return await this.#executeQuery(query) as QueryObjectResult<T>; } catch (e) { if (e instanceof PostgresError) { await this.commit(); throw new TransactionError(this.name, e); } else { throw e; } } } /** * Rollbacks are a mechanism to undo transaction operations without compromising the data that was modified during * the transaction * * A rollback can be executed the following way * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * // Very very important operations that went very, very wrong * await transaction.rollback(); // Like nothing ever happened * ``` * * Calling a rollback without arguments will terminate the current transaction and undo all changes, * but it can be used in conjuction with the savepoint feature to rollback specific changes like the following * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * // Important operations I don't want to rollback * const savepoint = await transaction.savepoint("before_disaster"); * await transaction.queryArray`UPDATE MY_TABLE SET X = 0`; // Oops, update without where * await transaction.rollback(savepoint); // "before_disaster" would work as well * // Everything that happened between the savepoint and the rollback gets undone * await transaction.commit(); // Commits all other changes * ``` * * The rollback method allows you to specify a "chain" option, that allows you to not only undo the current transaction * but to restart it with the same parameters in a single statement * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * // Transaction operations I want to undo * await transaction.rollback({ chain: true }); // All changes are undone, but the following statements will be executed inside a transaction as well * await transaction.queryArray`DELETE SOMETHING FROM SOMEWHERE`; // Still inside the transaction * await transaction.commit(); // The transaction finishes for good * ``` * * However, the "chain" option can't be used alongside a savepoint, even though they are similar * * A savepoint is meant to reset progress up to a certain point, while a chained rollback is meant to reset all progress * and start from scratch * * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * // @ts-expect-error * await transaction.rollback({ chain: true, savepoint: "my_savepoint" }); // Error, can't both return to savepoint and reset transaction * ``` * https://www.postgresql.org/docs/14/sql-rollback.html */ async rollback(savepoint?: string | Savepoint): Promise<void>; async rollback(options?: { savepoint?: string | Savepoint }): Promise<void>; async rollback(options?: { chain?: boolean }): Promise<void>; async rollback( savepoint_or_options?: string | Savepoint | { savepoint?: string | Savepoint; } | { chain?: boolean }, ): Promise<void> { this.#assertTransactionOpen(); let savepoint_option: Savepoint | string | undefined; if ( typeof savepoint_or_options === "string" || savepoint_or_options instanceof Savepoint ) { savepoint_option = savepoint_or_options; } else { savepoint_option = (savepoint_or_options as { savepoint?: string | Savepoint })?.savepoint; } let savepoint_name: string | undefined; if (savepoint_option instanceof Savepoint) { savepoint_name = savepoint_option.name; } else if (typeof savepoint_option === "string") { savepoint_name = savepoint_option.toLowerCase(); } let chain_option = false; if (typeof savepoint_or_options === "object") { chain_option = (savepoint_or_options as { chain?: boolean })?.chain ?? false; } if (chain_option && savepoint_name) { throw new Error( "The chain option can't be used alongside a savepoint on a rollback operation", ); } // If a savepoint is provided, rollback to that savepoint, continue the transaction if (typeof savepoint_option !== "undefined") { const ts_savepoint = this.#savepoints.find(({ name }) => name === savepoint_name ); if (!ts_savepoint) { throw new Error( `There is no "${savepoint_name}" savepoint registered in this transaction`, ); } if (!ts_savepoint.instances) { throw new Error( `There are no savepoints of "${savepoint_name}" left to rollback to`, ); } await this.queryArray(`ROLLBACK TO ${savepoint_name}`); return; } // If no savepoint is provided, rollback the whole transaction and check for the chain operator // in order to decide whether to restart the transaction or end it try { await this.queryArray(`ROLLBACK ${chain_option ? "AND CHAIN" : ""}`); } catch (e) { if (e instanceof PostgresError) { await this.commit(); throw new TransactionError(this.name, e); } else { throw e; } } this.#resetTransaction(); if (!chain_option) { this.#updateClientLock(null); } } /** * This method will generate a savepoint, which will allow you to reset transaction states * to a previous point of time * * Each savepoint has a unique name used to identify it, and it must abide the following rules * * - Savepoint names must start with a letter or an underscore * - Savepoint names are case insensitive * - Savepoint names can't be longer than 63 characters * - Savepoint names can only have alphanumeric characters * * A savepoint can be easily created like this * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * const savepoint = await transaction.savepoint("MY_savepoint"); // returns a `Savepoint` with name "my_savepoint" * await transaction.rollback(savepoint); * await savepoint.release(); // The savepoint will be removed * ``` * All savepoints can have multiple positions in a transaction, and you can change or update * this positions by using the `update` and `release` methods * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * const savepoint = await transaction.savepoint("n1"); * await transaction.queryArray`INSERT INTO MY_TABLE VALUES (${'A'}, ${2})`; * await savepoint.update(); // The savepoint will continue from here * await transaction.queryArray`DELETE FROM MY_TABLE`; * await transaction.rollback(savepoint); // The transaction will rollback before the delete, but after the insert * await savepoint.release(); // The last savepoint will be removed, the original one will remain * await transaction.rollback(savepoint); // It rolls back before the insert * await savepoint.release(); // All savepoints are released * ``` * * Creating a new savepoint with an already used name will return you a reference to * the original savepoint * ```ts * import { Client } from "../client.ts"; * * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * const savepoint_a = await transaction.savepoint("a"); * await transaction.queryArray`DELETE FROM MY_TABLE`; * const savepoint_b = await transaction.savepoint("a"); // They will be the same savepoint, but the savepoint will be updated to this position * await transaction.rollback(savepoint_a); // Rolls back to savepoint_b * ``` * https://www.postgresql.org/docs/14/sql-savepoint.html */ async savepoint(name: string): Promise<Savepoint> { this.#assertTransactionOpen(); if (!/^[a-zA-Z_]{1}[\w]{0,62}$/.test(name)) { if (!Number.isNaN(Number(name[0]))) { throw new Error("The savepoint name can't begin with a number"); } if (name.length > 63) { throw new Error( "The savepoint name can't be longer than 63 characters", ); } throw new Error( "The savepoint name can only contain alphanumeric characters", ); } name = name.toLowerCase(); let savepoint = this.#savepoints.find((sv) => sv.name === name); if (savepoint) { try { await savepoint.update(); } catch (e) { if (e instanceof PostgresError) { await this.commit(); throw new TransactionError(this.name, e); } else { throw e; } } } else { savepoint = new Savepoint( name, async (name: string) => { await this.queryArray(`SAVEPOINT ${name}`); }, async (name: string) => { await this.queryArray(`RELEASE SAVEPOINT ${name}`); }, ); try { await savepoint.update(); } catch (e) { if (e instanceof PostgresError) { await this.commit(); throw new TransactionError(this.name, e); } else { throw e; } } this.#savepoints.push(savepoint); } return savepoint; } }