@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.
505 lines (476 loc) • 15.8 kB
text/typescript
import { Connection } from "./connection/connection.js";
import {
ClientConfiguration,
ClientOptions,
ConnectionString,
createParams,
} from "./connection/connection_params.js";
import {
Query,
QueryArguments,
QueryArrayResult,
QueryObjectOptions,
QueryObjectResult,
QueryOptions,
QueryResult,
ResultType,
templateStringToQuery,
} from "./query/query.js";
import { Transaction, TransactionOptions } from "./query/transaction.js";
import { isTemplateString } from "./utils/utils.js";
export interface Session {
/**
* This is the code for the transaction currently locking the connection.
* If there is no transaction ongoing, the transaction code will be null
*/
current_transaction: string | null;
/**
* This is the process id of the current session as assigned by the database
* on connection. This id will undefined when there is no connection stablished
*/
pid: number | undefined;
/**
* Indicates if the connection is being carried over TLS. It will be undefined when
* there is no connection stablished
*/
tls: boolean | undefined;
/**
* This indicates the protocol used to connect to the database
*
* The two supported transports are TCP and Unix sockets
*/
transport: "tcp" | "socket" | undefined;
}
export abstract class QueryClient {
#connection: Connection;
#terminated = false;
#transaction: string | null = null;
constructor(connection: Connection) {
this.#connection = connection;
}
get connected() {
return this.#connection.connected;
}
get session(): Session {
return {
current_transaction: this.#transaction,
pid: this.#connection.pid,
tls: this.#connection.tls,
transport: this.#connection.transport,
};
}
#assertOpenConnection() {
if (this.#terminated) {
throw new Error(
"Connection to the database has been terminated",
);
}
}
protected async closeConnection() {
if (this.connected) {
await this.#connection.end();
}
this.resetSessionMetadata();
}
/**
* Transactions are a powerful feature that guarantees safe operations by allowing you to control
* the outcome of a series of statements and undo, reset, and step back said operations to
* your liking
*
* In order to create a transaction, use the `createTransaction` method in your client as follows:
*
* ```ts
* import { Client } from "./client.ts";
*
* const client = new Client();
* const transaction = client.createTransaction("my_transaction_name");
*
* await transaction.begin();
* // All statements between begin and commit will happen inside the transaction
* await transaction.commit(); // All changes are saved
* ```
*
* All statements that fail in query execution will cause the current transaction to abort and release
* the client without applying any of the changes that took place inside it
*
* ```ts
* import { Client } from "./client.ts";
*
* const client = new Client();
* const transaction = client.createTransaction("transaction");
*
* await transaction.begin();
* await transaction.queryArray`INSERT INTO MY_TABLE (X) VALUES ${"some_value"}`;
* try {
* await transaction.queryArray`SELECT []`; // Invalid syntax, transaction aborted, changes won't be applied
* }catch(e){
* await transaction.commit(); // Will throw, current transaction has already finished
* }
* ```
*
* This however, only happens if the error is of execution in nature, validation errors won't abort
* the transaction
*
* ```ts
* import { Client } from "./client.ts";
*
* const client = new Client();
* const transaction = client.createTransaction("transaction");
*
* await transaction.begin();
* await transaction.queryArray`INSERT INTO MY_TABLE (X) VALUES ${"some_value"}`;
* try {
* await transaction.rollback("unexistent_savepoint"); // Validation error
* } catch(e) {
* await transaction.commit(); // Transaction will end, changes will be saved
* }
* ```
*
* A transaction has many options to ensure modifications made to the database are safe and
* have the expected outcome, which is a hard thing to accomplish in a database with many concurrent users,
* and it does so by allowing you to set local levels of isolation to the transaction you are about to begin
*
* Each transaction can execute with the following levels of isolation:
*
* - Read committed: This is the normal behavior of a transaction. External changes to the database
* will be visible inside the transaction once they are committed.
*
* - Repeatable read: This isolates the transaction in a way that any external changes to the data we are reading
* won't be visible inside the transaction until it has finished
* ```ts
* import { Client } from "./client.ts";
*
* const client = new Client();
* const transaction = await client.createTransaction("my_transaction", { isolation_level: "repeatable_read" });
* ```
*
* - Serializable: This isolation level prevents the current transaction from making persistent changes
* if the data they were reading at the beginning of the transaction has been modified (recommended)
* ```ts
* import { Client } from "./client.ts";
*
* const client = new Client();
* const transaction = await client.createTransaction("my_transaction", { isolation_level: "serializable" });
* ```
*
* Additionally, each transaction allows you to set two levels of access to the data:
*
* - Read write: This is the default mode, it allows you to execute all commands you have access to normally
*
* - Read only: Disables all commands that can make changes to the database. Main use for the read only mode
* is to in conjuction with the repeatable read isolation, ensuring the data you are reading does not change
* during the transaction, specially useful for data extraction
* ```ts
* import { Client } from "./client.ts";
*
* const client = new Client();
* const transaction = await client.createTransaction("my_transaction", { read_only: true });
* ```
*
* Last but not least, transactions allow you to share starting point snapshots between them.
* For example, if you initialized a repeatable read transaction before a particularly sensible change
* in the database, and you would like to start several transactions with that same before the change state
* you can do the following:
*
* ```ts
* import { Client } from "./client.ts";
*
* const client_1 = new Client();
* const client_2 = new Client();
* const transaction_1 = client_1.createTransaction("transaction_1");
*
* 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/tutorial-transactions.html
* https://www.postgresql.org/docs/14/sql-set-transaction.html
*/
createTransaction(name: string, options?: TransactionOptions): Transaction {
this.#assertOpenConnection();
return new Transaction(
name,
options,
this,
// Bind context so function can be passed as is
this.#executeQuery.bind(this),
(name: string | null) => {
this.#transaction = name;
},
);
}
/**
* Every client must initialize their connection previously to the
* execution of any statement
*/
async connect(): Promise<void> {
if (!this.connected) {
await this.#connection.startup(false);
this.#terminated = false;
}
}
/**
* Closing your PostgreSQL connection will delete all non-persistent data
* that may have been created in the course of the session and will require
* you to reconnect in order to execute further queries
*/
async end(): Promise<void> {
await this.closeConnection();
this.#terminated = true;
}
#executeQuery<T extends Array<unknown>>(
_query: Query<ResultType.ARRAY>,
): Promise<QueryArrayResult<T>>;
#executeQuery<T>(
_query: Query<ResultType.OBJECT>,
): Promise<QueryObjectResult<T>>;
#executeQuery(
query: Query<ResultType>,
): Promise<QueryResult> {
return this.#connection.query(query);
}
/**
* 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 my_client = new Client();
*
* const {rows} = await my_client.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 my_client = new Client();
* const { rows } = await my_client.queryArray<[number, string]>(
* "SELECT ID, NAME FROM CLIENTS"
* ); // Array<[number, string]>
* ```
*
* It also allows you to execute prepared statements with template strings
*
* ```ts
* import { Client } from "./client.ts";
* const my_client = new Client();
*
* const id = 12;
* // Array<[number, string]>
* const {rows} = await my_client.queryArray<[number, string]>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`;
* ```
*/
queryArray<T extends Array<unknown>>(
query: string,
args?: QueryArguments,
): Promise<QueryArrayResult<T>>;
queryArray<T extends Array<unknown>>(
config: QueryOptions,
): Promise<QueryArrayResult<T>>;
queryArray<T extends Array<unknown>>(
strings: TemplateStringsArray,
...args: unknown[]
): Promise<QueryArrayResult<T>>;
queryArray<T extends Array<unknown> = Array<unknown>>(
query_template_or_config: TemplateStringsArray | string | QueryOptions,
...args: unknown[] | [QueryArguments | undefined]
): Promise<QueryArrayResult<T>> {
this.#assertOpenConnection();
if (this.#transaction !== null) {
throw new Error(
`This connection is currently locked by the "${this.#transaction}" transaction`,
);
}
let query: Query<ResultType.ARRAY>;
if (typeof query_template_or_config === "string") {
query = new Query(
query_template_or_config,
ResultType.ARRAY,
args[0] 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);
}
return this.#executeQuery(query);
}
/**
* 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 my_client = new Client();
*
* {
* const { rows } = await my_client.queryObject(
* "SELECT ID, NAME FROM CLIENTS"
* ); // Record<string, unknown>
* }
*
* {
* const { rows } = await my_client.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 my_client = new Client();
*
* {
* const {rows} = await my_client.queryObject(
* "SELECT ID, NAME FROM CLIENTS"
* );
*
* console.log(rows); // [{id: 78, name: "Frank"}, {id: 15, name: "Sarah"}]
* }
*
* {
* const {rows} = await my_client.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 statements with template strings
*
* ```ts
* import { Client } from "./client.ts";
*
* const my_client = new Client();
* const id = 12;
* // Array<{id: number, name: string}>
* const { rows } = await my_client.queryObject<{id: number, name: string}>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`;
* ```
*/
queryObject<T>(
query: string,
args?: QueryArguments,
): Promise<QueryObjectResult<T>>;
queryObject<T>(
config: QueryObjectOptions,
): Promise<QueryObjectResult<T>>;
queryObject<T>(
query: TemplateStringsArray,
...args: unknown[]
): Promise<QueryObjectResult<T>>;
queryObject<
T = Record<string, unknown>,
>(
query_template_or_config:
| string
| QueryObjectOptions
| TemplateStringsArray,
...args: unknown[] | [QueryArguments | undefined]
): Promise<QueryObjectResult<T>> {
this.#assertOpenConnection();
if (this.#transaction !== null) {
throw new Error(
`This connection is currently locked by the "${this.#transaction}" transaction`,
);
}
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,
);
}
return this.#executeQuery<T>(query);
}
protected resetSessionMetadata() {
this.#transaction = null;
}
}
/**
* Clients allow you to communicate with your PostgreSQL database and execute SQL
* statements asynchronously
*
* ```ts
* import { Client } from "./client.ts";
*
* const client = new Client();
* await client.connect();
* await client.queryArray`UPDATE MY_TABLE SET MY_FIELD = 0`;
* await client.end();
* ```
*
* A client will execute all their queries in a sequencial fashion,
* for concurrency capabilities check out connection pools
*
* ```ts
* import { Client } from "./client.ts";
*
* const client_1 = new Client();
* await client_1.connect();
* // Even if operations are not awaited, they will be executed in the order they were
* // scheduled
* client_1.queryArray`UPDATE MY_TABLE SET MY_FIELD = 0`;
* client_1.queryArray`DELETE FROM MY_TABLE`;
*
* const client_2 = new Client();
* await client_2.connect();
* // `client_2` will execute it's queries in parallel to `client_1`
* const {rows: result} = await client_2.queryArray`SELECT * FROM MY_TABLE`;
*
* await client_1.end();
* await client_2.end();
* ```
*/
export class Client extends QueryClient {
constructor(config?: ClientOptions | ConnectionString) {
super(
new Connection(createParams(config), async () => {
await this.closeConnection();
}),
);
}
}
export class PoolClient extends QueryClient {
#release: () => void;
constructor(config: ClientConfiguration, releaseCallback: () => void) {
super(
new Connection(config, async () => {
await this.closeConnection();
}),
);
this.#release = releaseCallback;
}
release() {
this.#release();
// Cleanup all session related metadata
this.resetSessionMetadata();
}
}