Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 72 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

In-memory relational database with **Kinyarwanda SQL**. Records live in named tables. CRUD matches [`json-base`](https://www.npmjs.com/package/@ndzhwr/json-base): get (with `where` and `limit`), add, set, del, and delAll. The relational operators are selection (`aho`) and inner join (`huza`).

The package is standalone. It reads and writes a `.db` page file, runs a `.kindb` script, and is what Kin calls through `KIN_UBUBIKO`.
The package is standalone. It reads and writes a `.db` page file, runs a `.kindb` script, and is what Kin calls through `KIN_UBUBIKO`. Collection models are imported from `@kin-lang/db/orm`.

```bash
npm install @kin-lang/db
Expand Down Expand Up @@ -102,7 +102,20 @@ db.del({ collection: 'abanyeshuri', where: { id: 2 } })
db.delAll()
```

`Database.memory()` never touches the disk until `save(path)`. `execute` rolls the in-memory pages back when a statement fails, and it does not write the file in that case. The rollback keeps the previous page buffers; it does not copy the whole file. A statement that changes nothing does not rewrite the file. `add`, `set`, and `del` write the pages that changed. `get` returns copies of the rows. `imipaka` and `get(..., limit)` stop once that many rows have matched. An equality `huza` is hashed, so it does not build the cartesian product.
```ts
const rows = db.transaction((tx) => {
tx.createCollection('abanyeshuri')
tx.add({ collection: 'abanyeshuri', data: { id: 1, izina: 'Aline', amanota: 88 } })
tx.add({ collection: 'abanyeshuri', data: { id: 2, izina: 'Keza', amanota: 41 } })
tx.set({ collection: 'abanyeshuri', where: { id: 1 }, data: { amanota: 95 } })
tx.del({ collection: 'abanyeshuri', where: { id: 2 } })
return tx.get({ collection: 'abanyeshuri' })
})
```

`Database.memory()` never touches the disk until `save(path)`. `execute` rolls the in-memory pages back when a statement fails, and it does not write the file in that case. The rollback keeps the previous page buffers; it does not copy the whole file. A statement that changes nothing does not rewrite the file. Outside a transaction, `add`, `set`, and `del` write the pages that changed. `get` returns copies of the rows. `imipaka` and `get(..., limit)` stop once that many rows have matched. An equality `huza` is hashed, so it does not build the cartesian product.

`transaction(callback)` is the explicit way to group those writes. The callback receives a transaction handle with the same methods. `add`, `set`, `del`, `execute`, and the other mutating methods — on that handle or on the database — change memory only while the callback runs. They do not each write the file. A model from `@kin-lang/db/orm` joins that transaction through `kora`, `shira`, `hindura`, and `siba`. The file is written once when the outermost callback returns. A throw restores the pages from the start of that callback and leaves the file untouched. The callback must finish synchronously; a returned promise is rejected and the changes are rolled back. `save()` inside a callback throws `E_TRANSACTION`. A nested `transaction()` is a savepoint: catching its error keeps the outer changes, and only the outermost success writes the file.

`where` accepts the json-base operators `lt`, `lte`, `gt`, `gte`, and `eq`, plus `munsi`, `munsi_cyangwa`, `hejuru`, `hejuru_cyangwa`, and `ni`. `get`, `set`, and `del` use the same planner as `hitamo`, so an equality on an indexed column does not walk the table.

Expand All @@ -111,6 +124,42 @@ const script = new URL('./examples/ishuri.kindb', import.meta.url)
Database.runFile(script, { db: './ishuri.db' })
```

## ORM

`import { model } from '@kin-lang/db/orm'` describes one collection. `on` binds that description to a database or a transaction. `kora`, `shira`, `hitamo`, `hindura`, and `siba` call that session. Inside `transaction()`, those calls change memory only, and the file is written once when the outermost callback returns.

Field types are `number`, `string`, `boolean`, and `any`. The Kinyarwanda names `umubare`, `ijambo`, `ukuri`, and `ikintu` name the same types. `null` is allowed in every field. `shira` and `hindura` accept the listed fields, and a value has to match its field. `where` uses the same comparisons as `Database.get`. Each key has to be a field on the model.

```ts
import { Database } from '@kin-lang/db'
import { model } from '@kin-lang/db/orm'

const Abanyeshuri = model('abanyeshuri', {
id: 'number',
izina: 'string',
amanota: 'number',
yize: 'boolean',
})

const db = Database.open('./ishuri.db', { create: true })
const abanyeshuri = Abanyeshuri.on(db)
abanyeshuri.kora()
abanyeshuri.shira({ id: 1, izina: 'Aline', amanota: 88, yize: true })
abanyeshuri.shira({ id: 2, izina: 'Keza', amanota: 41, yize: false })
abanyeshuri.hitamo({ where: { amanota: { gte: 50 } }, limit: 10 })
abanyeshuri.hindura({ where: { id: 1 }, data: { amanota: 90 } })
abanyeshuri.siba({ where: { id: 2 } })

const rows = db.transaction((tx) => {
const scoped = Abanyeshuri.on(tx)
scoped.shira({ id: 3, izina: 'Kaliza', amanota: 70, yize: true })
scoped.hindura({ where: { id: 1 }, data: { amanota: 95 } })
return scoped.hitamo()
})
```

`kora` fails when the collection already exists. The same `model(...)` value can be bound to more than one database.

## CLI

```bash
Expand Down Expand Up @@ -144,11 +193,28 @@ From a `.kin` file, after this package is installed next to Kin:
reka db = KIN_UBUBIKO.fungura("ishuri.db")
db.baza("KORA ITSINDA abanyeshuri")
db.shira("abanyeshuri", { id: 1, izina: "Aline", amanota: 88 })
db.shira("abanyeshuri", { id: 2, izina: "Keza", amanota: 41 })
reka abatsinze = db.hitamo("abanyeshuri", { amanota: { hejuru_cyangwa: 50 } })
tangaza_amakuru(abatsinze)
db.bika()

porogaramu_ntoya andika(tx) {
tx.shira("abanyeshuri", { id: 3, izina: "Kaliza", amanota: 70 })
tx.hindura("abanyeshuri", { id: 1 }, { amanota: 95 })
tx.siba("abanyeshuri", { id: 2 })
}
db.igikorwa(andika)

reka Abanyeshuri = db.icyitegererezo("abanyeshuri", {
id: "umubare",
izina: "ijambo",
amanota: "umubare"
})
Abanyeshuri.shira({ id: 4, izina: "Kaliza", amanota: 70 })
reka batsinze = Abanyeshuri.hitamo({ amanota: { hejuru_cyangwa: 50 } })
```

`igikorwa` is `transaction`. `shira`, `hindura`, and `siba` inside it do not write on their own. The file is written once when the function returns. `bika` inside `igikorwa` is an error. `icyitegererezo` is the model for one collection, imported in JavaScript from `@kin-lang/db/orm`. `kora`, `shira`, `hindura`, and `siba` on that model join an open `igikorwa`. The collection already exists from `baza`, so this model does not call `kora`.

`KIN_UBUBIKO.mu_mutwe()` is a memory database. Paths are relative to the `.kin` file, same as `KIN_INYANDIKO`. See `examples/ububiko` in the Kin repository.

## Errors
Expand All @@ -166,8 +232,9 @@ Failures throw `KinDbError` with a `code`:
| `E_NOT_FOUND` | `hindura` / `siba` matched nothing. |
| `E_DUPLICATE` | `hindura` matched more than one row. |
| `E_OPERATOR` | The comparison is not one of lt/gt/lte/gte/eq. |
| `E_TYPE` | A value, limit, or name has the wrong shape. |
| `E_TYPE` | A value, limit, name, or model field has the wrong shape. |
| `E_IO` | The `.db` or `.kindb` file cannot be read or written. |
| `E_TRANSACTION` | `save()` ran inside a transaction, or the callback returned a promise. |

## Development

Expand All @@ -177,4 +244,4 @@ npm test
npm run build
```

The published entry is `dist/index.js`. The `kindb` binary is `dist/cli.js`.
The published entries are `dist/index.js` and `dist/orm.js` (`@kin-lang/db/orm`). The `kindb` binary is `dist/cli.js`.
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
"require": "./dist/index.js",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./orm": {
"types": "./dist/orm.d.ts",
"require": "./dist/orm.js",
"import": "./dist/orm.js",
"default": "./dist/orm.js"
}
},
"engines": {
Expand All @@ -34,7 +40,8 @@
"database",
"json",
"sql",
"kindb"
"kindb",
"orm"
],
"author": "Kin contributors",
"license": "MIT",
Expand Down
96 changes: 96 additions & 0 deletions src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,30 @@ export interface RunFileOptions {
create?: boolean;
}

/**
* The database handle passed to {@link Database.transaction}.
* Its methods are the same mutations as {@link Database}, but they do not
* write the file. The open transaction writes once, when its callback returns.
*/
export interface Transaction {
execute(source: string): ExecuteResult;
/** Run a script whose last statement must be HITAMO. Returns those rows. */
query(source: string): Row[];
collections(): string[];
createCollection(name: string, columns?: ColumnDef[]): void;
dropCollection(name: string): void;
add(params: { collection: string; data: Row }): Row;
get(params: { collection: string; where?: Where; limit?: number }): Row[];
set(params: { collection: string; where: Where; data: Row }): void;
del(params: { collection: string; where: Where }): number;
delAll(): void;
/**
* A nested transaction is a savepoint. It does not write the file.
* A throw restores only the changes made inside this callback.
*/
transaction<T>(body: (tx: Transaction) => T): T;
}

/**
* In-memory relational database that can be tied to a `.db` page file.
* Kinyarwanda SQL is executed with {@link Database.execute}. The same data
Expand All @@ -27,6 +51,8 @@ export interface RunFileOptions {
export class Database {
private store: Store;
private filePath: string | null;
/** Open transaction callbacks. Mutations skip the file until the outermost one returns. */
private transactionDepth = 0;

private constructor(store: Store, filePath: string | null) {
this.store = store;
Expand Down Expand Up @@ -69,9 +95,48 @@ export class Database {
return this.filePath;
}

/**
* Run `body` as one transaction.
*
* `add`, `set`, `del`, and the other mutating methods change memory only
* while `body` is running, whether they are called on `tx` or on this
* database. They do not each write the file. The file is written once
* when the outermost callback returns. A throw restores the pages from
* the start of this callback and does not write.
*
* The callback must finish synchronously. A nested {@link transaction}
* is a savepoint: catching its error keeps the outer changes.
*/
transaction<T>(body: (tx: Transaction) => T): T {
if (typeof body !== 'function') {
throw new KinDbError('E_TYPE', 'transaction() expects a function');
}
const snapshot = this.store.clone();
this.transactionDepth += 1;
let released = false;
try {
const value = body(transactionView(this));
if (isThenable(value)) {
throw new KinDbError(
'E_TRANSACTION',
'transaction() callback must finish synchronously',
);
}
this.transactionDepth -= 1;
released = true;
if (this.transactionDepth === 0) this.commit();
return value;
} catch (error) {
this.store = snapshot;
if (!released) this.transactionDepth -= 1;
throw error;
}
}

/**
* Run Kinyarwanda SQL. A failure rolls the in-memory data back to the
* state before this call, and does not write the `.db` file.
* Inside {@link transaction}, the write waits for that callback to return.
*/
execute(source: string): ExecuteResult {
const snapshot = this.store.clone();
Expand All @@ -96,6 +161,12 @@ export class Database {

/** Write the `.db` file. Passing a path binds future saves to that file. */
save(filePath?: string): void {
if (this.transactionDepth > 0) {
throw new KinDbError(
'E_TRANSACTION',
'save() cannot run inside a transaction. The transaction writes the file when the callback returns',
);
}
if (filePath) {
const next = resolve(filePath);
const full = next !== this.filePath;
Expand Down Expand Up @@ -150,11 +221,36 @@ export class Database {
}

private commit(): void {
if (this.transactionDepth > 0) return;
if (!this.filePath) return;
this.store.writeTo(this.filePath, false);
}
}

function transactionView(db: Database): Transaction {
return {
execute: (source) => db.execute(source),
query: (source) => db.query(source),
collections: () => db.collections(),
createCollection: (name, columns) => db.createCollection(name, columns),
dropCollection: (name) => db.dropCollection(name),
add: (params) => db.add(params),
get: (params) => db.get(params),
set: (params) => db.set(params),
del: (params) => db.del(params),
delAll: () => db.delAll(),
transaction: (body) => db.transaction(body),
};
}

function isThenable(value: unknown): boolean {
return (
(typeof value === 'object' || typeof value === 'function') &&
value !== null &&
typeof (value as { then?: unknown }).then === 'function'
);
}

function readStore(filePath: string): Store {
let bytes: Buffer;
try {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export { KinDbError, isKinDbError } from './errors';
export { Database } from './database';
export type { OpenOptions, RunFileOptions } from './database';
export type { OpenOptions, RunFileOptions, Transaction } from './database';
export { executeSource, executeStatements } from './execute';
export type { ExecuteResult } from './execute';
export { parse } from './parser/parser';
Expand Down
Loading
Loading