@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
text/typescript
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;
}
}