Skip to content

Commit

Permalink
Move hrana-client out of sqld repo
Browse files Browse the repository at this point in the history
  • Loading branch information
honzasp committed Mar 1, 2023
0 parents commit 82e17df
Show file tree
Hide file tree
Showing 18 changed files with 1,196 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
/lib-esm
/lib-cjs
package-lock.json
*.tsbuildinfo
Session.vim
20 changes: 20 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
MIT License

Copyright 2023 the sqld authors

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Hrana client for TypeScript

This package implements a Hrana client for TypeScript. Hrana is a protocol based on WebSockets that can be used to connect to sqld. It is more efficient than the postgres wire protocol (especially for edge deployments) and it supports interactive stateful SQL connections (called "streams") which are not supported by the HTTP API.

> This package is intended mostly for internal use. Consider using the [`@libsql/client`][libsql-client] package, which will automatically use Hrana if you connect to a `ws://` or `wss://` URL.
[libsql-client]: https://www.npmjs.com/package/@libsql/client

## Usage

```typescript
import * as hrana from "@libsql/hrana-client";

// Open a `hrana.Client`, which works like a connection pool in standard SQL
// databases, but it uses just a single network connection internally
const url = process.env.URL ?? "ws://localhost:2023"; // Address of the sqld server
const jwt = process.env.JWT; // JWT token for authentication
const client = hrana.open(url, jwt);

// Open a `hrana.Stream`, which is an interactive SQL stream. This corresponds
// to a "connection" from other SQL databases
const stream = client.openStream();

// Fetch all rows returned by a SQL statement
const books = await stream.query("SELECT title, year FROM book WHERE author = 'Jane Austen'");
// The rows are returned in an Array...
for (const book of books) {
// every returned row works as an array (`book[1]`) and as an object (`book.year`)
console.log(`${book.title} from ${book.year}`);
}

// Fetch a single row
const book = await stream.queryRow("SELECT title, MIN(year) FROM book");
if (book !== undefined) {
console.log(`The oldest book is ${book.title} from year ${book[1]}`);
}

// Fetch a single value, using a bound parameter
const year = await stream.queryValue(["SELECT MAX(year) FROM book WHERE author = ?", ["Jane Austen"]]);
if (year !== undefined) {
console.log(`Last book from Jane Austen was published in ${year}`);
}

// Execute a statement that does not return any rows
const res = await stream.execute(["DELETE FROM book WHERE author = ?", ["J. K. Rowling"]])
console.log(`${res.rowsAffected} books have been cancelled`);

// When you are done, remember to close the client
client.close();
```
6 changes: 6 additions & 0 deletions examples/jwt_auth.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as hrana from "@libsql/hrana-client";

const client = hrana.open(process.env.URL ?? "ws://localhost:2023", process.env.JWT);
const stream = client.openStream();
console.log(await stream.queryValue("SELECT 1"));
client.close();
58 changes: 58 additions & 0 deletions examples/readme_example.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as hrana from "@libsql/hrana-client";

// Open a `hrana.Client`, which works like a connection pool in standard SQL
// databases, but it uses just a single network connection internally
const url = process.env.URL ?? "ws://localhost:2023"; // Address of the sqld server
const jwt = process.env.JWT; // JWT token for authentication
const client = hrana.open(url, jwt);

// Open a `hrana.Stream`, which is an interactive SQL stream. This corresponds
// to a "connection" from other SQL databases
const stream = client.openStream();

await stream.execute(`CREATE TABLE book (
id INTEGER PRIMARY KEY NOT NULL,
author TEXT NOT NULL,
title TEXT NOT NULL,
year INTEGER NOT NULL
)`);
await stream.execute(`INSERT INTO book (author, title, year) VALUES
('Jane Austen', 'Sense and Sensibility', 1811),
('Jane Austen', 'Pride and Prejudice', 1813),
('Jane Austen', 'Mansfield Park', 1814),
('Jane Austen', 'Emma', 1815),
('Jane Austen', 'Persuasion', 1818),
('Jane Austen', 'Lady Susan', 1871),
('Daniel Defoe', 'Robinson Crusoe', 1719),
('Daniel Defoe', 'A Journal of the Plague Year', 1722),
('J. K. Rowling', 'Harry Potter and the Philosopher''s Stone', 1997),
('J. K. Rowling', 'The Casual Vacancy', 2012),
('J. K. Rowling', 'The Ickabog', 2020)
`);

// Fetch all rows returned by a SQL statement
const books = await stream.query("SELECT title, year FROM book WHERE author = 'Jane Austen'");
// The rows are returned in an Array...
for (const book of books) {
// every returned row works as an array (`book[1]`) and as an object (`book.year`)
console.log(`${book.title} from ${book.year}`);
}

// Fetch a single row
const book = await stream.queryRow("SELECT title, MIN(year) FROM book");
if (book !== undefined) {
console.log(`The oldest book is ${book.title} from year ${book[1]}`);
}

// Fetch a single value, using a bound parameter
const year = await stream.queryValue(["SELECT MAX(year) FROM book WHERE author = ?", ["Jane Austen"]]);
if (year !== undefined) {
console.log(`Last book from Jane Austen was published in ${year}`);
}

// Execute a statement that does not return any rows
const res = await stream.execute(["DELETE FROM book WHERE author = ?", ["J. K. Rowling"]])
console.log(`${res.rowsAffected} books have been cancelled`);

// When you are done, remember to close the client
client.close();
6 changes: 6 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
preset: "ts-jest/presets/default-esm",
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
}
3 changes: 3 additions & 0 deletions package-cjs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
57 changes: 57 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"name": "@libsql/hrana-client",
"version": "0.1.0",
"keywords": [
"hrana",
"libsql",
"sqld",
"database"
],
"description": "Hrana client for connecting to sqld over a WebSocket",
"repository": {
"type": "git",
"url": "github:libsql/hrana-client-ts"
},
"homepage": "https://github.com/libsql/hrana-client-ts",
"authors": [
"Jan Špaček <[email protected]>"
],
"license": "MIT",

"type": "module",
"main": "lib-cjs/index.js",
"types": "lib-esm/index.d.ts",
"exports": {
".": {
"types": "./lib-esm/index.d.ts",
"import": "./lib-esm/index.js",
"require": "./lib-cjs/index.js"
}
},

"scripts": {
"prebuild": "rm -rf ./lib-cjs ./lib-esm",
"build": "npm run build:cjs && npm run build:esm",
"build:cjs": "tsc -p tsconfig.build-cjs.json",
"build:esm": "tsc -p tsconfig.build-esm.json",
"postbuild": "cp package-cjs.json ./lib-cjs/package.json",

"test": "jest"
},

"files": [
"lib-cjs/**",
"lib-esm/**"
],

"dependencies": {
"isomorphic-ws": "^5.0.0"
},
"devDependencies": {
"@types/jest": "^29.4.0",
"@types/ws": "^8.5.4",
"jest": "^29.4.0",
"ts-jest": "^29.0.5",
"typescript": "^4.9.4"
}
}
173 changes: 173 additions & 0 deletions src/__tests__/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import * as hrana from "..";

function withClient(f: (c: hrana.Client) => Promise<void>): () => Promise<void> {
return async () => {
const c = hrana.open("ws://localhost:2023");
try {
await f(c);
} finally {
c.close();
}
};
}

test("Stream.queryValue()", withClient(async (c) => {
const s = c.openStream();
expect(await s.queryValue("SELECT 1")).toStrictEqual(1);
expect(await s.queryValue("SELECT 'elephant'")).toStrictEqual("elephant");
expect(await s.queryValue("SELECT 42.5")).toStrictEqual(42.5);
expect(await s.queryValue("SELECT NULL")).toStrictEqual(null);
}));

test("Stream.queryRow()", withClient(async (c) => {
const s = c.openStream();

const row = await s.queryRow(
"SELECT 1 AS one, 'elephant' AS two, 42.5 AS three, NULL as four");
expect(row[0]).toStrictEqual(1);
expect(row[1]).toStrictEqual("elephant");
expect(row[2]).toStrictEqual(42.5);
expect(row[3]).toStrictEqual(null);

expect(row[0]).toStrictEqual(row.one);
expect(row[1]).toStrictEqual(row.two);
expect(row[2]).toStrictEqual(row.three);
expect(row[3]).toStrictEqual(row.four);
}));

test("Stream.query()", withClient(async (c) => {
const s = c.openStream();

await s.execute("BEGIN");
await s.execute("DROP TABLE IF EXISTS t");
await s.execute("CREATE TABLE t (one, two, three, four)");
await s.execute(
`INSERT INTO t VALUES
(1, 'elephant', 42.5, NULL),
(2, 'hippopotamus', '123', 0.0)`
);

const rows = await s.query("SELECT * FROM t ORDER BY one");
expect(rows.length).toStrictEqual(2);
expect(rows.rowsAffected).toStrictEqual(0);

const row0 = rows[0];
expect(row0[0]).toStrictEqual(1);
expect(row0[1]).toStrictEqual("elephant");
expect(row0["three"]).toStrictEqual(42.5);
expect(row0["four"]).toStrictEqual(null);

const row1 = rows[1];
expect(row1["one"]).toStrictEqual(2);
expect(row1["two"]).toStrictEqual("hippopotamus");
expect(row1[2]).toStrictEqual("123");
expect(row1[3]).toStrictEqual(0.0);
}));

test("Stream.execute()", withClient(async (c) => {
const s = c.openStream();

let res = await s.execute("BEGIN");
expect(res.rowsAffected).toStrictEqual(0);

res = await s.execute("DROP TABLE IF EXISTS t");
expect(res.rowsAffected).toStrictEqual(0);

res = await s.execute("CREATE TABLE t (num, word)");
expect(res.rowsAffected).toStrictEqual(0);

res = await s.execute("INSERT INTO t VALUES (1, 'one'), (2, 'two'), (3, 'three')");
expect(res.rowsAffected).toStrictEqual(3);

const rows = await s.query("SELECT * FROM t ORDER BY num");
expect(rows.length).toStrictEqual(3);
expect(rows.rowsAffected).toStrictEqual(0);

res = await s.execute("DELETE FROM t WHERE num >= 2");
expect(res.rowsAffected).toStrictEqual(2);

res = await s.execute("UPDATE t SET num = 4, word = 'four'");
expect(res.rowsAffected).toStrictEqual(1);

res = await s.execute("DROP TABLE t");
expect(res.rowsAffected).toStrictEqual(0);

await s.execute("COMMIT");
}));

test("Stream.executeRaw()", withClient(async (c) => {
const s = c.openStream();

let res = await s.executeRaw({
"sql": "SELECT 1 as one, ? as two, NULL as three",
"args": [{"type": "text", "value": "1+1"}],
"want_rows": true,
});

expect(res.cols).toStrictEqual([
{"name": "one"},
{"name": "two"},
{"name": "three"},
]);
expect(res.rows).toStrictEqual([
[
{"type": "integer", "value": "1"},
{"type": "text", "value": "1+1"},
{"type": "null"},
],
]);
}));

test("concurrent streams are separate", withClient(async (c) => {
const s1 = c.openStream();
await s1.execute("DROP TABLE IF EXISTS t");
await s1.execute("CREATE TABLE t (number)");
await s1.execute("INSERT INTO t VALUES (1)");

const s2 = c.openStream();

await s1.execute("BEGIN");

await s2.execute("BEGIN");
await s2.execute("INSERT INTO t VALUES (10)");

expect(await s1.queryValue("SELECT SUM(number) FROM t")).toStrictEqual(1);
expect(await s2.queryValue("SELECT SUM(number) FROM t")).toStrictEqual(11);
}));

test("concurrent operations are correctly ordered", withClient(async (c) => {
const s = c.openStream();
await s.execute("DROP TABLE IF EXISTS t");
await s.execute("CREATE TABLE t (stream, value)");

async function stream(streamId: number): Promise<void> {
const s = c.openStream();

let value = "s" + streamId;
await s.execute(["INSERT INTO t VALUES (?, ?)", [streamId, value]]);

const promises: Array<Promise<any>> = [];
const expectedValues = [];
for (let i = 0; i < 10; ++i) {
const promise = s.queryValue([
"UPDATE t SET value = value || ? WHERE stream = ? RETURNING value",
["_" + i, streamId],
]);
value = value + "_" + i;
promises.push(promise);
expectedValues.push(value);
}

for (let i = 0; i < promises.length; ++i) {
expect(await promises[i]).toStrictEqual(expectedValues[i]);
}

s.close();
}

const promises = [];
for (let i = 0; i < 10; ++i) {
promises.push(stream(i));
}
await Promise.all(promises);
}));
Loading

0 comments on commit 82e17df

Please sign in to comment.