Skip to content

Commit

Permalink
Introduce TransactionMode
Browse files Browse the repository at this point in the history
  • Loading branch information
honzasp committed Jun 12, 2023
1 parent f01e7c9 commit 76c6238
Show file tree
Hide file tree
Showing 12 changed files with 184 additions and 59 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Added `TransactionMode` argument to `batch()` and `transaction()`

## 0.2.0 -- 2023-06-07

- **Added support for interactive transactions over HTTP** by using `@libsql/hrana-client` version 0.4 ([#44](https://github.com/libsql/libsql-client-ts/pull/44))
Expand Down
2 changes: 1 addition & 1 deletion examples/src/example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ async function example() {
url
};
const db = createClient(config);
await db.batch([
await db.batch("write", [
"CREATE TABLE IF NOT EXISTS users (email TEXT)",
"INSERT INTO users (email) VALUES ('alice@example.com')",
"INSERT INTO users (email) VALUES ('bob@example.com')"
Expand Down
72 changes: 45 additions & 27 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ describe("execute()", () => {
}));

test("rowsAffected with INSERT", withClient(async (c) => {
await c.batch([
await c.batch("write", [
"DROP TABLE IF EXISTS t",
"CREATE TABLE t (a)",
]);
Expand All @@ -116,7 +116,7 @@ describe("execute()", () => {
}));

test("rowsAffected with DELETE", withClient(async (c) => {
await c.batch([
await c.batch("write", [
"DROP TABLE IF EXISTS t",
"CREATE TABLE t (a)",
"INSERT INTO t VALUES (1), (2), (3), (4), (5)",
Expand All @@ -126,7 +126,7 @@ describe("execute()", () => {
}));

test("lastInsertRowid with INSERT", withClient(async (c) => {
await c.batch([
await c.batch("write", [
"DROP TABLE IF EXISTS t",
"CREATE TABLE t (a)",
"INSERT INTO t VALUES ('one'), ('two')",
Expand All @@ -141,7 +141,7 @@ describe("execute()", () => {
}));

test("rows from INSERT RETURNING", withClient(async (c) => {
await c.batch([
await c.batch("write", [
"DROP TABLE IF EXISTS t",
"CREATE TABLE t (a)",
]);
Expand All @@ -153,7 +153,7 @@ describe("execute()", () => {
}));

(server != "test_v1" ? test : test.skip)("rowsAffected with WITH INSERT", withClient(async (c) => {
await c.batch([
await c.batch("write", [
"DROP TABLE IF EXISTS t",
"CREATE TABLE t (a)",
"INSERT INTO t VALUES (1), (2), (3)",
Expand Down Expand Up @@ -307,7 +307,7 @@ describe("arguments", () => {

describe("batch()", () => {
test("multiple queries", withClient(async (c) => {
const rss = await c.batch([
const rss = await c.batch("read", [
"SELECT 1+1",
"SELECT 1 AS one, 2 AS two",
{sql: "SELECT ?", args: ["boomerang"]},
Expand All @@ -332,7 +332,7 @@ describe("batch()", () => {
}));

test("statements are executed sequentially", withClient(async (c) => {
const rss = await c.batch([
const rss = await c.batch("write", [
/* 0 */ "DROP TABLE IF EXISTS t",
/* 1 */ "CREATE TABLE t (a, b)",
/* 2 */ "INSERT INTO t VALUES (1, 'one')",
Expand All @@ -353,7 +353,7 @@ describe("batch()", () => {
}));

test("statements are executed in a transaction", withClient(async (c) => {
await c.batch([
await c.batch("write", [
"DROP TABLE IF EXISTS t1",
"DROP TABLE IF EXISTS t2",
"CREATE TABLE t1 (a)",
Expand All @@ -365,7 +365,7 @@ describe("batch()", () => {
for (let i = 0; i < n; ++i) {
const ii = i;
promises.push((async () => {
const rss = await c.batch([
const rss = await c.batch("write", [
{sql: "INSERT INTO t1 VALUES (?)", args: [ii]},
{sql: "INSERT INTO t2 VALUES (?)", args: [ii * 10]},
"SELECT SUM(a) FROM t1",
Expand All @@ -383,10 +383,10 @@ describe("batch()", () => {
expect(rs1.rows[0][0]).toStrictEqual(n*(n-1)/2);
const rs2 = await c.execute("SELECT SUM(a) FROM t2");
expect(rs2.rows[0][0]).toStrictEqual(n*(n-1)/2*10);
}));
}), 10000);

test("error in batch", withClient(async (c) => {
await expect(c.batch([
await expect(c.batch("read", [
"SELECT 1+1",
"SELECT foobar",
])).rejects.toBeLibsqlError();
Expand All @@ -396,7 +396,7 @@ describe("batch()", () => {
await c.execute("DROP TABLE IF EXISTS t");
await c.execute("CREATE TABLE t (a)");
await c.execute("INSERT INTO t VALUES ('one')");
await expect(c.batch([
await expect(c.batch("write", [
"INSERT INTO t VALUES ('two')",
"SELECT foobar",
"INSERT INTO t VALUES ('three')",
Expand All @@ -411,7 +411,7 @@ describe("batch()", () => {
for (let i = 0; i < 1000; ++i) {
stmts.push(`SELECT ${i}`);
}
const rss = await c.batch(stmts);
const rss = await c.batch("read", stmts);
for (let i = 0; i < stmts.length; ++i) {
expect(rss[i].rows[0][0]).toStrictEqual(i);
}
Expand All @@ -428,7 +428,7 @@ describe("batch()", () => {
}
}

const rss = await c.batch(stmts);
const rss = await c.batch("read", stmts);
for (let i = 0; i < n; ++i) {
for (let j = 0; j < m; ++j) {
const rs = rss[i*m + j];
Expand All @@ -437,11 +437,29 @@ describe("batch()", () => {
}
}
}));

test("deferred batch", withClient(async (c) => {
const rss = await c.batch("deferred", [
"SELECT 1+1",
"DROP TABLE IF EXISTS t",
"CREATE TABLE t (a)",
"INSERT INTO t VALUES (21) RETURNING 2*a",
]);

expect(rss.length).toStrictEqual(4);
const [rs0, _rs1, _rs2, rs3] = rss;

expect(rs0.rows.length).toStrictEqual(1);
expect(Array.from(rs0.rows[0])).toStrictEqual([2]);

expect(rs3.rows.length).toStrictEqual(1);
expect(Array.from(rs3.rows[0])).toStrictEqual([42]);
}));
});

describe("transaction()", () => {
test("query multiple rows", withClient(async (c) => {
const txn = await c.transaction();
const txn = await c.transaction("read");

const rs = await txn.execute("VALUES (1, 'one'), (2, 'two'), (3, 'three')");
expect(rs.columns.length).toStrictEqual(2);
Expand All @@ -455,12 +473,12 @@ describe("transaction()", () => {
}));

test("commit()", withClient(async (c) => {
await c.batch([
await c.batch("write", [
"DROP TABLE IF EXISTS t",
"CREATE TABLE t (a)",
]);

const txn = await c.transaction();
const txn = await c.transaction("write");
await txn.execute("INSERT INTO t VALUES ('one')");
await txn.execute("INSERT INTO t VALUES ('two')");
expect(txn.closed).toStrictEqual(false);
Expand All @@ -473,12 +491,12 @@ describe("transaction()", () => {
}));

test("rollback()", withClient(async (c) => {
await c.batch([
await c.batch("write", [
"DROP TABLE IF EXISTS t",
"CREATE TABLE t (a)",
]);

const txn = await c.transaction();
const txn = await c.transaction("write");
await txn.execute("INSERT INTO t VALUES ('one')");
await txn.execute("INSERT INTO t VALUES ('two')");
expect(txn.closed).toStrictEqual(false);
Expand All @@ -491,12 +509,12 @@ describe("transaction()", () => {
}));

test("close()", withClient(async (c) => {
await c.batch([
await c.batch("write", [
"DROP TABLE IF EXISTS t",
"CREATE TABLE t (a)",
]);

const txn = await c.transaction();
const txn = await c.transaction("write");
await txn.execute("INSERT INTO t VALUES ('one')");
expect(txn.closed).toStrictEqual(false);
txn.close();
Expand All @@ -508,12 +526,12 @@ describe("transaction()", () => {
}));

test("error does not rollback", withClient(async (c) => {
await c.batch([
await c.batch("write", [
"DROP TABLE IF EXISTS t",
"CREATE TABLE t (a)",
]);

const txn = await c.transaction();
const txn = await c.transaction("write");
await expect(txn.execute("SELECT foo")).rejects.toBeLibsqlError();
await txn.execute("INSERT INTO t VALUES ('one')");
await expect(txn.execute("SELECT bar")).rejects.toBeLibsqlError();
Expand All @@ -524,12 +542,12 @@ describe("transaction()", () => {
}));

test("commit empty", withClient(async (c) => {
const txn = await c.transaction();
const txn = await c.transaction("read");
await txn.commit();
}));

test("rollback empty", withClient(async (c) => {
const txn = await c.transaction();
const txn = await c.transaction("read");
await txn.rollback();
}));
});
Expand All @@ -549,7 +567,7 @@ const hasNetworkErrors = isWs && (server == "test_v1" || server == "test_v2");
}));

test(`${title} in transaction()`, withClient(async (c) => {
const txn = await c.transaction();
const txn = await c.transaction("read");
await expect(txn.execute(sql)).rejects.toBeLibsqlError("HRANA_WEBSOCKET_ERROR");
await expect(txn.commit()).rejects.toBeLibsqlError("HRANA_WEBSOCKET_ERROR");
txn.close();
Expand All @@ -558,7 +576,7 @@ const hasNetworkErrors = isWs && (server == "test_v1" || server == "test_v2");
}));

test(`${title} in batch()`, withClient(async (c) => {
await expect(c.batch(["SELECT 42", sql, "SELECT 24"]))
await expect(c.batch("read", ["SELECT 42", sql, "SELECT 24"]))
.rejects.toBeLibsqlError("HRANA_WEBSOCKET_ERROR");

expect((await c.execute("SELECT 42")).rows[0][0]).toStrictEqual(42);
Expand Down
56 changes: 54 additions & 2 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ export interface Client {
*
* The batch is executed in its own logical database connection and the statements are wrapped in a
* transaction. This ensures that the batch is applied atomically: either all or no changes are applied.
*
* The `mode` parameter selects the transaction mode for the batch; please see {@link TransactionMode} for
* details.
*
* If any of the statements in the batch fails with an error, the batch is aborted, the transaction is
* rolled back and the returned promise is rejected.
Expand All @@ -61,7 +64,7 @@ export interface Client {
* {@link transaction} method.
*
* ```javascript
* const rss = await client.batch([
* const rss = await client.batch("write", [
* // batch statement without arguments
* "DELETE FROM books WHERE name LIKE '%Crusoe'",
*
Expand All @@ -79,6 +82,15 @@ export interface Client {
* ]);
* ```
*/
batch(mode: TransactionMode, stmts: Array<InStatement>): Promise<Array<ResultSet>>;

/** Execute a batch of SQL statement in the `"write"` transaction mode.
*
* Please see {@link batch} for details.
*
* @deprecated Please specify the `mode` explicitly. The default `"write"` will be removed in the next
* major release.
*/
batch(stmts: Array<InStatement>): Promise<Array<ResultSet>>;

/** Starts an interactive transaction.
Expand All @@ -87,12 +99,15 @@ export interface Client {
* logic. They can be used if the {@link batch} method is too restrictive, but please note that
* interactive transactions have higher latency.
*
* The `mode` parameter selects the transaction mode for the interactive transaction; please see {@link
* TransactionMode} for details.
*
* You **must** make sure that the returned {@link Transaction} object is closed, by calling {@link
* Transaction.close}, {@link Transaction.commit} or {@link Transaction.rollback}. The best practice is
* to call {@link Transaction.close} in a `finally` block, as follows:
*
* ```javascript
* const transaction = client.transaction();
* const transaction = client.transaction("write");
* try {
* // do some operations with the transaction here
* ...
Expand All @@ -105,6 +120,15 @@ export interface Client {
* }
* ```
*/
transaction(mode: TransactionMode): Promise<Transaction>;

/** Starts an interactive transaction in `"write"` mode.
*
* Please see {@link transaction} for details.
*
* @deprecated Please specify the `mode` explicitly. The default `"write"` will be removed in the next
* major release.
*/
transaction(): Promise<Transaction>;

/** Close the client and release resources.
Expand Down Expand Up @@ -189,6 +213,34 @@ export interface Transaction {
closed: boolean;
}

/** Transaction mode.
*
* The client supports multiple modes for transactions:
*
* - `"write"` is a read-write transaction, started with `BEGIN IMMEDIATE`. This transaction mode supports
* both read statements (`SELECT`) and write statements (`INSERT`, `UPDATE`, `CREATE TABLE`, etc). The libSQL
* server cannot process multiple write transactions concurrently, so if there is another write transaction
* already started, our transaction will wait in a queue before it can begin.
*
* - `"read"` is a read-only transaction, started with `BEGIN TRANSACTION READONLY` (a libSQL extension). This
* transaction mode supports only reads (`SELECT`) and will not accept write statements. The libSQL server can
* handle multiple read transactions at the same time, so we don't need to wait for other transactions to
* complete. A read-only transaction can also be executed on a local replica, so it provides lower latency.
*
* - `"deferred"` is a transaction started with `BEGIN DEFERRED`, which starts as a read transaction, but the
* first write statement will try to upgrade it to a write transaction. However, this upgrade may fail if
* there already is a write transaction executing on the server, so you should be ready to handle these
* failures.
*
* If your transaction includes only read statements, `"read"` is always preferred over `"deferred"` or
* `"write"`, because `"read"` transactions can be executed on a replica and don't block other transactions.
*
* If your transaction includes both read and write statements, you should be using the `"write"` mode most of
* the time. Use the `"deferred"` mode only if you prefer to fail the write transaction instead of waiting for
* the previous write transactions to complete.
*/
export type TransactionMode = "write" | "read" | "deferred";

/** Result of executing an SQL statement.
*
* ```javascript
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Config } from "./api.js";
import { LibsqlError } from "./api.js";
import { supportedUrlLink } from "./help.js";
import type { Authority } from "./uri.js";
import { parseUri } from "./uri.js";
import { supportedUrlLink } from "./util.js";

export interface ExpandedConfig {
scheme: ExpandedScheme;
Expand Down
1 change: 0 additions & 1 deletion src/help.ts

This file was deleted.

Loading

0 comments on commit 76c6238

Please sign in to comment.