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
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ crate-type = ["cdylib"]
# with (ADR 0002), so a revision is the honest way to say which one.
# A local checkout is used instead with a `paths` override in
# `.cargo/config.toml`, which is untracked on purpose.
zudb = { package = "zu", git = "https://github.com/tamnd/zu", rev = "92c9a5e9f1f0d5f4d89bf7321e5710a4fcb861f1" }
zu-common = { git = "https://github.com/tamnd/zu", rev = "92c9a5e9f1f0d5f4d89bf7321e5710a4fcb861f1" }
zudb = { package = "zu", git = "https://github.com/tamnd/zu", rev = "8aa27d9c9df4087522f4314ea5b1d5850df27f8d" }
zu-common = { git = "https://github.com/tamnd/zu", rev = "8aa27d9c9df4087522f4314ea5b1d5850df27f8d" }
# N-API by way of napi-rs (ADR 0002). `napi9` is the version of N-API
# this addon declares it needs, which is what makes one binary work
# across Node 24, Node 26, Electron and Bun without a rebuild: the
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The rows are an array, so iterating them is `for (const row of rows)` and nothin

## What works today

`connect`, `query`, `exec`, `stream`, `close`, `dispose` and `await using`. Named parameters both ways, including lists, records and nesting. Every scalar the engine has, plus nodes, edges and paths with their tables named rather than numbered, and `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`, with `{ temporal: true }` and `toTemporal()` for the runtimes that have `Temporal`. Read-only connections, memory and thread limits. `bigIntMode`, per statement or per connection. An `AbortSignal` on any statement. The full error surface above, and `isZuError` to recognize it. Streaming, as an async iterable, as batches and as a Web Stream. Both module formats, typed separately.
`connect`, `query`, `exec`, `stream`, `close`, `dispose` and `await using`. Named parameters both ways, including lists, records and nesting. Every scalar the engine has, plus nodes, edges and paths with their tables named rather than numbered, and `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`, with `{ temporal: true }` and `toTemporal()` for the runtimes that have `Temporal`. Read-only connections, memory and thread limits. `bigIntMode`, per statement or per connection. An `AbortSignal` on any statement. The full error surface above, and `isZuError` to recognize it. Streaming, as an async iterable, as batches and as a Web Stream. Transactions, with `inTransaction` on the connection. Both module formats, typed separately.

Build it with `npm run build`, and run the suite with `npm test`. Nothing is published yet, so `npm i zudb` is not a thing you can type at anybody's terminal, but everything it will do is built and installed on every run of the release workflow.

Expand Down Expand Up @@ -77,6 +77,31 @@ Between the statement and the loop sit two batches, which is the whole of the bu

A statement that has to see every row before it can give one, which is `ORDER BY`, `DISTINCT` and the aggregates, runs whole and is handed over in batches afterwards. The loop is the same either way and `summary.streamed` is what tells them apart.

## Several statements as one unit of work

One statement is atomic on its own. `conn.transaction()` is how two of them stand or fall together:

```ts
const tx = await conn.transaction();
await conn.exec(`INSERT (p:Person {id: $id, name: $name})`, { id: 3n, name: "ida" });
await conn.exec(`INSERT (p:Person {id: $id, name: $name})`, { id: 4n, name: "eve" });
await tx.commit();
```

The statements are still the connection's, because the span is the connection's and not a second handle to it. `conn.inTransaction` says whether one is open, and it is the session's own answer rather than a tally kept here, so a caller who would rather write `START TRANSACTION`, `COMMIT` and `ROLLBACK` as statements gets the same answer from it. `{ readOnly: true }` starts a span that refuses the statement that writes. Nesting is refused by the engine, with the engine's own condition.

`await using tx` rolls back. That is the opposite of what the Python client's `with` block does, and the difference is in the language rather than in the database: a Python context manager is handed the exception unwinding through it and can tell a block that ended well from one that failed, and a JavaScript disposal is told nothing at all. A disposal that committed would commit half the work of a block that threw, which is the one thing a transaction exists to prevent. So the commit is the word the caller writes, and leaving the block without writing it undoes the span:

```ts
{
await using tx = await conn.transaction();
await conn.exec(`INSERT (p:Person {id: 5, name: 'zoe'})`);
await tx.commit(); // without this line the insert is undone
}
```

A block that ends well and forgets to commit loses its work, which is a loud kind of wrong and shows up the first time the code runs. The alternative was a block that failed and kept half of what it did, which is a quiet kind and shows up in production. Committing or rolling back twice is refused as a `ZuUsageError` rather than ignored, since the statements after the first end belong to no transaction of yours. Leaving the block of a transaction whose connection has already been closed says nothing, because a closed connection took the unwritten span with it and there is nothing left to undo.

## Asking for numbers instead of bigints

`bigIntMode` says how INT64 is spelled on the way out. It goes on one statement, or on a connection for all of them, and a statement on a connection that named one may still name the other:
Expand Down
1 change: 1 addition & 0 deletions binding.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,7 @@ if (!nativeBinding) {

module.exports = nativeBinding
module.exports.Connection = nativeBinding.Connection
module.exports.Transaction = nativeBinding.Transaction
module.exports.ZuCursor = nativeBinding.ZuCursor
module.exports.ZuDate = nativeBinding.ZuDate
module.exports.ZuDuration = nativeBinding.ZuDuration
Expand Down
95 changes: 95 additions & 0 deletions binding.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,21 @@ export interface ZuStatementOptions {
readonly signal?: AbortSignal
}

/**
* What a transaction takes when it starts.
*/
export interface ZuTransactionOptions {
/**
* Starts it `READ ONLY`, which the engine refuses a write inside of
* at the statement that writes rather than at this call.
*
* Worth asking for on a span that only reads, because saying so is
* how a statement that was not meant to write is stopped by the
* database rather than by review.
*/
readonly readOnly?: boolean
}

/**
* What a failed call throws.
*
Expand Down Expand Up @@ -333,6 +348,42 @@ export declare class Connection {
get readOnly(): boolean
/** Whether the connection is still open. */
get open(): boolean
/**
* Whether an explicit transaction is running on this connection.
*
* True inside a `transaction()` and true after a `START
* TRANSACTION` written by hand, because it is asked of the session
* rather than counted here. A statement written on its own runs in
* a transaction of its own and this stays false for it: what it
* answers is whether a span is open, not whether anything is
* atomic.
*/
get inTransaction(): boolean
/**
* Starts a transaction and hands it back.
*
* It starts here rather than at the first statement inside it, so a
* transaction that cannot start says so at the line that asked. A
* connection is inside one transaction at a time and asking for a
* second while one is open is refused by the engine rather than
* nested, because a transaction inside a transaction is a promise
* this database does not make.
*
* ```js
* await using tx = await conn.transaction()
* await conn.exec('INSERT (a:account {uid: 1, balance: 100})')
* await conn.exec('INSERT (b:account {uid: 2, balance: 0})')
* await tx.commit()
* ```
*
* The `await using` is the rollback nobody remembers to write. It
* undoes the transaction unless the block committed it, which is
* the opposite of what Python's `with` block does here and is the
* only honest reading in JavaScript: a disposal is not told whether
* the scope it is leaving threw, so a disposal that committed would
* commit half of the work of a block that failed.
*/
transaction(options?: ZuTransactionOptions | null): Promise<Transaction>
/**
* Runs one statement and gives back its rows.
*
Expand Down Expand Up @@ -379,6 +430,50 @@ export declare class Connection {
dispose(): Promise<void>
}

/**
* A transaction that has been started and not yet ended.
*
* Take one with `Connection.transaction`. It starts when it is taken,
* so a transaction that cannot start says so at the line that asked,
* and the statements that run inside it are the ones written on the
* connection it came from.
*/
export declare class Transaction {
/**
* Whether this transaction was started `READ ONLY`, which the
* engine refuses a write inside of at the statement that writes.
*/
get readOnly(): boolean
/**
* Whether this transaction has already been committed or rolled
* back.
*/
get done(): boolean
/**
* Ends the transaction and keeps what it wrote.
*
* Doing it twice is refused rather than ignored. A second commit is
* a program that has lost track of where its transaction ends, and
* the statements between the two are in neither of them.
*/
commit(): Promise<void>
/** Ends the transaction and throws away what it wrote. */
rollback(): Promise<void>
/**
* The undo `await using` calls, which is the intended way to scope
* a transaction.
*
* It rolls back, and it does nothing at all when the transaction
* has already ended, which is what makes a committed block and a
* failed one both leave through here without saying anything.
*
* It is also reachable as `Symbol.asyncDispose`, which is what
* `await using` actually looks for and which [`wire_disposal`] puts
* on every transaction as it is made.
*/
dispose(): Promise<void>
}

/**
* One statement, read a batch at a time.
*
Expand Down
16 changes: 16 additions & 0 deletions etc/zudb.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ export class Connection {
cursor(statement: string, params?: Record<string, ZuParam> | null, options?: ZuStreamOptions | null): ZuCursor
dispose(): Promise<void>
exec(statement: string, params?: Record<string, ZuParam> | null, options?: ZuStatementOptions | null): Promise<void>
get inTransaction(): boolean
get open(): boolean
get path(): string
query<Row = Record<string, ZuValue>>(statement: string, params?: Record<string, ZuParam> | null, options?: ZuStatementOptions | null): Promise<ZuRows<Row>>
get readOnly(): boolean
transaction(options?: ZuTransactionOptions | null): Promise<Transaction>
}

// @public
Expand All @@ -34,6 +36,15 @@ export interface ConnectOptions {
// @public
export function isZuError(value: unknown): value is ZuError

// @public
export class Transaction {
commit(): Promise<void>
dispose(): Promise<void>
get done(): boolean
get readOnly(): boolean
rollback(): Promise<void>
}

// @public
export function version(): string

Expand Down Expand Up @@ -252,6 +263,11 @@ export class ZuTimestamp {
toTemporal(): ZuPlainDateTime | ZuZonedDateTime
}

// @public
export interface ZuTransactionOptions {
readonly readOnly?: boolean
}

// @public
export type ZuValue =
| null
Expand Down
Loading
Loading