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 = "8aa27d9c9df4087522f4314ea5b1d5850df27f8d" }
zu-common = { git = "https://github.com/tamnd/zu", rev = "8aa27d9c9df4087522f4314ea5b1d5850df27f8d" }
zudb = { package = "zu", git = "https://github.com/tamnd/zu", rev = "95c7c9909f3a2624515d27eb436da52936016960" }
zu-common = { git = "https://github.com/tamnd/zu", rev = "95c7c9909f3a2624515d27eb436da52936016960" }
# 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
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,28 @@ The rows are an array, so iterating them is `for (const row of rows)` and nothin
- **A refusal is a rejection.** A closed connection, a statement that is not a string and a parameter of a type nothing can bind are all refused inside the promise rather than thrown out of the call, so one `await` catches everything one statement can do and no caller has to wrap the same call twice. That holds for the arguments too: passing a number where a statement goes is a `ZuUsageError` the promise rejects with, not a `TypeError` off the stack.
- **Parameters are named, and nothing about them is guessed.** An object keyed by the names the statement uses, without the `$`. An array is refused rather than bound by position, because zu has no positional parameters and binding one by index would run the statement with none of the values the caller passed and say nothing about it. A value that contains itself is refused too, at a nesting depth no real value reaches.

## A database with no file

`connect()` with nothing after it is a database in memory, and it makes no file anywhere.

```ts
import { connect } from "zudb";

await using conn = await connect();

await conn.exec(`INSERT (p:Person {id: 1, name: 'ada'})`);
for (const { name } of await conn.query(`MATCH (p:Person) RETURN p.name AS name`)) {
console.log(name);
}
```

`connect(":memory:")` is the same thing spelled the way every embedded database spells it, and it makes no file called `:memory:` either, which is what it used to do. Options may stand where the path would, so `connect({ threads: 2 })` is a call and not a mistake.

It is the whole engine and not a reduced one: writes, transactions, the appender, registered frames and streams, all of it, on bytes that are not a file. `conn.memory` says which kind you have, since `path` cannot quite answer it on a filesystem that allows a colon in a name. Nothing survives the last connection, which is the point: a test, a script, or five minutes with the language costs no cleanup and leaves no `social.zu1` in a directory somebody has to notice later.

## 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. Transactions, with `inTransaction` on the connection. An appender, for loading rows a batch at a time, and `load` for building a whole database out of columns and an edge list. Registered frames, so an Arrow table or an object of typed arrays is something a statement can match on without the rows being copied. `columnar`, for a result read down its columns as the buffers themselves rather than across its rows as objects. Prepared statements, compiled at the line that asked and run as often as wanted, and `explain` and `profile`, as a tree a program walks and as the listing a person reads. 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, databases in memory, 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. An appender, for loading rows a batch at a time, and `load` for building a whole database out of columns and an edge list. Registered frames, so an Arrow table or an object of typed arrays is something a statement can match on without the rows being copied. `columnar`, for a result read down its columns as the buffers themselves rather than across its rows as objects. Prepared statements, compiled at the line that asked and run as often as wanted, and `explain` and `profile`, as a tree a program walks and as the listing a person reads. 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
14 changes: 13 additions & 1 deletion binding.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,11 @@ export declare class Connection {
get path(): string
/** Whether this connection refuses every statement that writes. */
get readOnly(): boolean
/**
* Whether the database behind it is in memory rather than on
* disk, in which case nothing survives the last connection to it.
*/
get memory(): boolean
/** Whether the connection is still open. */
get open(): boolean
/**
Expand Down Expand Up @@ -1377,8 +1382,15 @@ export declare function abiVersion(): string
* Creates one when the path holds nothing, which is what a first
* program expects and what every embedded database does. A read-only
* connection never creates anything.
*
* With no path, with `null`, or with `':memory:'`, the database is in
* memory and no file is made anywhere. It is the whole engine and not
* a reduced one, so it takes writes and transactions and the appender
* exactly as a database on disk does, and it is gone when the last
* connection to it is. Options may stand where the path would in that
* case, so `connect({ threads: 2 })` is a call and not a mistake.
*/
export declare function connect(path: string, options?: ConnectOptions | undefined | null): Promise<Connection>
export declare function connect(path?: string | ConnectOptions | undefined | null, options?: ConnectOptions | undefined | null): Promise<Connection>

/**
* What a connection can be opened with.
Expand Down
3 changes: 2 additions & 1 deletion etc/zudb.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export class Appender {
}

// @public
export function connect(path: string, options?: ConnectOptions | undefined | null): Promise<Connection>
export function connect(path?: string | ConnectOptions | undefined | null, options?: ConnectOptions | undefined | null): Promise<Connection>

// @public
export class Connection {
Expand All @@ -34,6 +34,7 @@ export class Connection {
exec(statement: string, params?: Record<string, ZuParam> | null, options?: ZuStatementOptions | null): Promise<void>
explain(statement: string): Promise<ZuPlan>
get inTransaction(): boolean
get memory(): boolean
get open(): boolean
get path(): string
prepare(statement: string): Promise<Prepared>
Expand Down
106 changes: 99 additions & 7 deletions src/conn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,38 +131,94 @@ pub struct Connection {
spelling: Spelling,
path: String,
read_only: bool,
/// Whether the database behind it is in memory, which is the one
/// thing [`Self::path`] cannot quite say: a file could be called
/// `:memory:` on any filesystem that allows a colon.
memory: bool,
}

/// The name a database in memory is asked for by, and answers to.
///
/// The spelling every embedded database has used for thirty years,
/// which is the reason it is this and not something better: a caller
/// who types it has already been taught what it means somewhere else.
pub(crate) const MEMORY: &str = ":memory:";

/// Opens the database at `path` and connects to it.
///
/// Creates one when the path holds nothing, which is what a first
/// program expects and what every embedded database does. A read-only
/// connection never creates anything.
///
/// With no path, with `null`, or with `':memory:'`, the database is in
/// memory and no file is made anywhere. It is the whole engine and not
/// a reduced one, so it takes writes and transactions and the appender
/// exactly as a database on disk does, and it is gone when the last
/// connection to it is. Options may stand where the path would in that
/// case, so `connect({ threads: 2 })` is a call and not a mistake.
#[napi(
ts_args_type = "path: string, options?: ConnectOptions | undefined | null",
ts_args_type = "path?: string | ConnectOptions | undefined | null, options?: ConnectOptions | undefined | null",
ts_return_type = "Promise<Connection>"
)]
pub fn connect(
env: &Env,
path: Unknown<'_>,
path: Option<Unknown<'_>>,
options: Option<ConnectOptions>,
) -> AsyncTask<ConnectTask> {
// Whether this runtime has `Temporal` is a question only the thread
// that owns the runtime may ask, so it is asked here and carried to
// the thread that opens the database, where the answer decides
// whether there is anything to open.
let has_temporal = temporal::present(env).unwrap_or(false);
let path = text(&path, "path");
let (path, options, refused) = arguments(path, options);
AsyncTask::new(ConnectTask {
path: path.as_deref().unwrap_or_default().to_string(),
refused: path.err(),
memory: path.is_none(),
path: path.unwrap_or_else(|| MEMORY.to_string()),
refused,
options,
has_temporal,
})
}

/// Which of the three shapes the call was written in.
///
/// A path and options, options alone, or neither. The first argument
/// is read here rather than declared, because a value that is a string
/// in one call and an object in the next is a value napi would refuse
/// before this client got to say anything about it.
///
/// The path comes back as `None` when the database is in memory, which
/// is the one thing the three shapes have to agree on.
fn arguments(
first: Option<Unknown<'_>>,
second: Option<ConnectOptions>,
) -> (Option<String>, Option<ConnectOptions>, Option<String>) {
let Some(first) = first else {
return (None, second, None);
};
let kind = match first.get_type() {
Ok(kind) => kind,
Err(err) => return (None, second, Some(err.reason)),
};
match kind {
ValueType::Undefined | ValueType::Null => (None, second, None),
ValueType::Object => match ConnectOptions::from_unknown(first) {
Ok(options) => (None, Some(options), None),
Err(err) => (None, second, Some(err.reason)),
},
_ => match text(&first, "path") {
Ok(path) if path == MEMORY => (None, second, None),
Ok(path) => (Some(path), second, None),
Err(message) => (None, second, Some(message)),
},
}
}

pub struct ConnectTask {
path: String,
/// Whether the database is in memory, in which case [`Self::path`]
/// is the name it is asked for by rather than a name to open.
memory: bool,
/// What this client refused the call with, before any of it ran.
refused: Option<String>,
options: Option<ConnectOptions>,
Expand Down Expand Up @@ -225,8 +281,16 @@ impl<'task> ScopedTask<'task> for ConnectTask {
config = config.threads(threads as usize);
}
}
Ok(open(PathBuf::from(&self.path), read_only, config)
.map(|opened| Opened { spelling, ..opened })
let opened = match self.memory {
true => memory(config),
false => open(PathBuf::from(&self.path), read_only, config),
};
Ok(opened
.map(|opened| Opened {
spelling,
read_only,
..opened
})
.map_err(Failure::Engine))
}

Expand All @@ -240,6 +304,7 @@ impl<'task> ScopedTask<'task> for ConnectTask {
spelling: opened.spelling,
path: opened.path,
read_only: opened.read_only,
memory: opened.memory,
}
.into_instance(env)?;
wire_disposal(env, &mut instance, "dispose")?;
Expand Down Expand Up @@ -279,6 +344,25 @@ pub struct Opened {
spelling: Spelling,
path: String,
read_only: bool,
memory: bool,
}

/// Opens a database in memory, then connects.
///
/// The path is the name it was asked for by rather than the one the
/// engine spells it with: the engine mints a unique name per database
/// so two of them never share a writer, and that counter is its
/// business and not a caller's.
fn memory(config: Config) -> std::result::Result<Opened, ZuError> {
let database = Database::memory_with(config)?;
let conn = database.connect()?;
Ok(Opened {
conn,
spelling: Spelling::default(),
path: MEMORY.to_string(),
read_only: false,
memory: true,
})
}

/// Opens or creates, then connects.
Expand All @@ -300,6 +384,7 @@ fn open(path: PathBuf, read_only: bool, config: Config) -> std::result::Result<O
spelling: Spelling::default(),
path: stored,
read_only,
memory: false,
})
}

Expand All @@ -317,6 +402,13 @@ impl Connection {
self.read_only
}

/// Whether the database behind it is in memory rather than on
/// disk, in which case nothing survives the last connection to it.
#[napi(getter)]
pub fn memory(&self) -> bool {
self.memory
}

/// Whether the connection is still open.
#[napi(getter)]
pub fn open(&self) -> bool {
Expand Down
Loading
Loading