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
123 changes: 123 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
MAVEN_ARGS: -B -ntp

jobs:
# The API artifact is what a caller compiles against, it has no native
# code in it, and it is the one thing that has to build on every JDK
# this client claims to support. It needs no engine, so it answers in
# under a minute and it answers first.
api:
strategy:
fail-fast: false
matrix:
java: ["17", "21", "25", "26-ea"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

# Only the API module, because the FFM provider compiles to release
# 25 and a JDK 17 compiler cannot be asked for that. A caller on 17
# gets exactly this artifact and the JNI provider beside it.
- run: mvn $MAVEN_ARGS -pl zudb -am test

# The whole client against the engine at its own HEAD, which is what
# makes a red job here mean the binding is wrong about the ABI rather
# than that a checked-in copy of something is stale.
engine:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
java: ["25", "26-ea"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5

- uses: actions/checkout@v5
with:
repository: tamnd/zu
path: engine

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

- uses: Swatinem/rust-cache@v2
with:
workspaces: engine

# ZU_ABI_VERSION is a header macro rather than a symbol, so a
# binding with no C compile step has nowhere to read it from and
# has to write it down. This is the step that stops the written
# down copy from drifting.
- name: The ABI this client speaks is the ABI the engine offers
run: |
set -eu
engine_abi="$(sed -n 's/^#define ZU_ABI_VERSION "\(.*\)"$/\1/p' \
engine/crates/zu-capi/include/zu.h)"
client_abi="$(sed -n 's/.*ABI_VERSION = "\(.*\)";.*/\1/p' \
zudb/src/main/java/dev/zudb/Zu.java)"
test -n "$engine_abi"
test -n "$client_abi"
echo "engine $engine_abi, client $client_abi"
test "$engine_abi" = "$client_abi"

- name: Build libzu
working-directory: engine
run: cargo build --release -p zu-capi

- name: Where the library landed
run: |
set -eu
lib="$(ls engine/target/release/libzu.dylib engine/target/release/libzu.so 2>/dev/null | head -1)"
test -n "$lib"
echo "ZU_LIBRARY=$GITHUB_WORKSPACE/$lib" >> "$GITHUB_ENV"

- run: mvn $MAVEN_ARGS test

# The suite again with assertions on everywhere, including the ones
# in the JDK itself. The bounds checks a MemorySegment does are the
# difference between a wrong offset failing and a wrong offset
# reading somebody else's memory.
- run: mvn $MAVEN_ARGS test -Dzu.test.args="-ea -esa"

# Not for the numbers, which mean nothing on a shared runner, but
# because a benchmark is code that nothing else compiles and
# nothing else runs. One iteration is enough to say it still works.
- run: mvn $MAVEN_ARGS -DskipTests package

- run: java -jar zudb-bench/target/benchmarks.jar -f 1 -wi 1 -i 1 -r 1s -w 1s

# What Maven Central will run over the artifacts, run here instead so
# that a release is not the first time anyone sees it.
javadoc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "25"
cache: maven

- run: mvn $MAVEN_ARGS -P release -DskipTests package
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
.DS_Store
target/

85 changes: 77 additions & 8 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,9 +8,6 @@ import dev.zudb.*;
try (Database db = Database.open("social.zu1");
Connection conn = db.connect()) {

conn.execute("CREATE NODE TABLE Person(id INT64 PRIMARY KEY, name STRING)");
conn.loadCsv("Person", Path.of("people.csv"));

try (Result result = conn.query("""
MATCH (p:Person)-[:Follows]->(f)
RETURN p.name AS name, count(*) AS n ORDER BY n DESC LIMIT 5
Expand All@@ -28,26 +25,98 @@ try (Database db = Database.open("social.zu1");
<artifactId>zudb</artifactId>
<version>${zu.version}</version>
</dependency>
<dependency>
<groupId>dev.zudb</groupId>
<artifactId>zudb-ffm</artifactId>
<version>${zu.version}</version>
<scope>runtime</scope>
</dependency>
```

Text blocks for queries, try-with-resources for every handle, `Stream<Row>` for iteration. Nothing here should surprise a Java developer, which is the whole goal.

## Reading a column without reading a row

A row at a time is the shape most callers want, and it is not the shape that makes an embedded database worth embedding. Every column of a result is also readable as one borrowed buffer over the engine's own memory, with no copy and no per-row call:

```java
try (Result r = conn.query("MATCH (p:Person) RETURN p.age")) {
LongBuffer ages = r.longs(0);
ByteBuffer valid = r.valid(0);

long total = 0;
for (int i = 0; i < ages.remaining(); i++) {
if (valid.get(i) != 0) {
total += ages.get(i);
}
}
}
```

The buffers are read-only views in native byte order, and they are valid until the `Result` closes. A result larger than one chunk is readable a chunk at a time through `r.chunks()`, which is the path that does not need the whole column resident. `java.nio` rather than `MemorySegment` on purpose: a Java 17 caller can name a `LongBuffer`, and both providers can hand one back without copying.

What it is worth, summing one integer column of a hundred thousand rows on an M-series laptop, JDK 25:

| How | Per row |
|---|---|
| `r.longs(0)` and a loop over the buffer | 0.45 ns |
| the same a chunk at a time | 4.1 ns |
| `for (Row row : r) row.getLong(0)` | 45 ns |
| `r.stream().mapToLong(...)` | 67 ns |

A row at a time is a boundary crossing a cell, and a hundred crossings cost about what one borrowed buffer costs. Both surfaces are there because both are the right answer to a different question, but a loop over a million rows should be reading a column.

## How it binds

The Foreign Function and Memory API (Panama) is the primary path, with `jextract` generating the bindings from `zu.h` and `MemorySegment` giving genuinely zero-copy column access. There is no hand-written JNI shim on that path and no native code beyond `libzu` itself.
The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself.

An SDK that requires a recent JDK in 2026 excludes a large part of the enterprise ecosystem, so there is a JNI provider too:

| Artifact | Baseline | Role |
|---|---|---|
| `dev.zudb:zudb` | Java 17 | the API, no native code, no FFM types in the public surface |
| `dev.zudb:zudb-ffm` | Java 22+ | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17+ | the fallback provider |
| `dev.zudb:zudb-ffm` | Java 25 | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17 | the fallback provider |
| `dev.zudb:zudb-native-{platform}` | | the `libzu` binaries |

A `ServiceLoader` picks the provider at runtime and logs the choice once at debug level. Application code never names one. Baseline for the modern artifact is **Java 25 LTS**, CI runs 17, 21, 25, and 26.
A `ServiceLoader` picks the provider at run time and application code never names one. The FFM artifact targets Java 25 rather than the Java 22 that finalised the API, because 22 has been out of support since September 2024 and shipping against an unsupported release only moves the problem. CI runs 17, 21, 25, and 26.

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the class path case, the module path case wants `--enable-native-access=dev.zudb.ffm`, and the provider checks `Module::isNativeAccessEnabled` before the first downcall so that the failure is an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

## Errors

Every failure is a `ZuException`, and the subclass is chosen from the GQLSTATUS class rather than from the message: `ZuSyntaxException` for 42, `ZuDataException` for 22, `ZuTransactionException` for 25 and 40, and so on down. The exception carries the whole diagnostic, so a caller reads fields instead of parsing prose:

```java
catch (ZuSyntaxException e) {
e.code(); // the GQLSTATUS, for example 42001
e.condition(); // its standard text
e.position(); // line, column and byte offset, when there is one
e.caret().ifPresent(System.err::println);
e.retryable(); // whether running it again could work
}
```

## What works today

The engine has no DDL yet, so there is no `CREATE NODE TABLE` and nothing in this client writes a schema. What runs against a fresh database is the expression and projection surface: `RETURN`, `UNWIND`, parameters, lists, records, and the temporal types. The example at the top of this file describes the intended shape and needs a graph that some other tool built.

## Building

```sh
mvn test -Dzu.library=/path/to/libzu.dylib
```

The provider looks at `-Dzu.library`, then `ZU_LIBRARY`, then the platform library path. The tests skip rather than fail when no `libzu` is reachable, so a checkout with no engine build beside it is still green.

The benchmarks are JMH and are not published:

```sh
mvn package -DskipTests
ZU_LIBRARY=/path/to/libzu.dylib java -jar zudb-bench/target/benchmarks.jar
```

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the classpath case, the docs give the exact `--enable-native-access=dev.zudb` flag for the module path, and the binding detects the ungranted state at `Database.open` and throws a message containing the flag you need. A JVM warning on stderr three frames from any of our code is not a diagnosis anyone can act on.
`ZU_LIBRARY` rather than `-Dzu.library` there, because JMH forks a JVM of its own and a fork inherits the environment rather than the system properties.

## Beyond Java

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
123 changes: 123 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
MAVEN_ARGS: -B -ntp

jobs:
# The API artifact is what a caller compiles against, it has no native
# code in it, and it is the one thing that has to build on every JDK
# this client claims to support. It needs no engine, so it answers in
# under a minute and it answers first.
api:
strategy:
fail-fast: false
matrix:
java: ["17", "21", "25", "26-ea"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

# Only the API module, because the FFM provider compiles to release
# 25 and a JDK 17 compiler cannot be asked for that. A caller on 17
# gets exactly this artifact and the JNI provider beside it.
- run: mvn $MAVEN_ARGS -pl zudb -am test

# The whole client against the engine at its own HEAD, which is what
# makes a red job here mean the binding is wrong about the ABI rather
# than that a checked-in copy of something is stale.
engine:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
java: ["25", "26-ea"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5

- uses: actions/checkout@v5
with:
repository: tamnd/zu
path: engine

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

- uses: Swatinem/rust-cache@v2
with:
workspaces: engine

# ZU_ABI_VERSION is a header macro rather than a symbol, so a
# binding with no C compile step has nowhere to read it from and
# has to write it down. This is the step that stops the written
# down copy from drifting.
- name: The ABI this client speaks is the ABI the engine offers
run: |
set -eu
engine_abi="$(sed -n 's/^#define ZU_ABI_VERSION "\(.*\)"$/\1/p' \
engine/crates/zu-capi/include/zu.h)"
client_abi="$(sed -n 's/.*ABI_VERSION = "\(.*\)";.*/\1/p' \
zudb/src/main/java/dev/zudb/Zu.java)"
test -n "$engine_abi"
test -n "$client_abi"
echo "engine $engine_abi, client $client_abi"
test "$engine_abi" = "$client_abi"

- name: Build libzu
working-directory: engine
run: cargo build --release -p zu-capi

- name: Where the library landed
run: |
set -eu
lib="$(ls engine/target/release/libzu.dylib engine/target/release/libzu.so 2>/dev/null | head -1)"
test -n "$lib"
echo "ZU_LIBRARY=$GITHUB_WORKSPACE/$lib" >> "$GITHUB_ENV"

- run: mvn $MAVEN_ARGS test

# The suite again with assertions on everywhere, including the ones
# in the JDK itself. The bounds checks a MemorySegment does are the
# difference between a wrong offset failing and a wrong offset
# reading somebody else's memory.
- run: mvn $MAVEN_ARGS test -Dzu.test.args="-ea -esa"

# Not for the numbers, which mean nothing on a shared runner, but
# because a benchmark is code that nothing else compiles and
# nothing else runs. One iteration is enough to say it still works.
- run: mvn $MAVEN_ARGS -DskipTests package

- run: java -jar zudb-bench/target/benchmarks.jar -f 1 -wi 1 -i 1 -r 1s -w 1s

# What Maven Central will run over the artifacts, run here instead so
# that a release is not the first time anyone sees it.
javadoc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "25"
cache: maven

- run: mvn $MAVEN_ARGS -P release -DskipTests package
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
.DS_Store
target/

85 changes: 77 additions & 8 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,9 +8,6 @@ import dev.zudb.*;
try (Database db = Database.open("social.zu1");
Connection conn = db.connect()) {

conn.execute("CREATE NODE TABLE Person(id INT64 PRIMARY KEY, name STRING)");
conn.loadCsv("Person", Path.of("people.csv"));

try (Result result = conn.query("""
MATCH (p:Person)-[:Follows]->(f)
RETURN p.name AS name, count(*) AS n ORDER BY n DESC LIMIT 5
Expand All@@ -28,26 +25,98 @@ try (Database db = Database.open("social.zu1");
<artifactId>zudb</artifactId>
<version>${zu.version}</version>
</dependency>
<dependency>
<groupId>dev.zudb</groupId>
<artifactId>zudb-ffm</artifactId>
<version>${zu.version}</version>
<scope>runtime</scope>
</dependency>
```

Text blocks for queries, try-with-resources for every handle, `Stream<Row>` for iteration. Nothing here should surprise a Java developer, which is the whole goal.

## Reading a column without reading a row

A row at a time is the shape most callers want, and it is not the shape that makes an embedded database worth embedding. Every column of a result is also readable as one borrowed buffer over the engine's own memory, with no copy and no per-row call:

```java
try (Result r = conn.query("MATCH (p:Person) RETURN p.age")) {
LongBuffer ages = r.longs(0);
ByteBuffer valid = r.valid(0);

long total = 0;
for (int i = 0; i < ages.remaining(); i++) {
if (valid.get(i) != 0) {
total += ages.get(i);
}
}
}
```

The buffers are read-only views in native byte order, and they are valid until the `Result` closes. A result larger than one chunk is readable a chunk at a time through `r.chunks()`, which is the path that does not need the whole column resident. `java.nio` rather than `MemorySegment` on purpose: a Java 17 caller can name a `LongBuffer`, and both providers can hand one back without copying.

What it is worth, summing one integer column of a hundred thousand rows on an M-series laptop, JDK 25:

| How | Per row |
|---|---|
| `r.longs(0)` and a loop over the buffer | 0.45 ns |
| the same a chunk at a time | 4.1 ns |
| `for (Row row : r) row.getLong(0)` | 45 ns |
| `r.stream().mapToLong(...)` | 67 ns |

A row at a time is a boundary crossing a cell, and a hundred crossings cost about what one borrowed buffer costs. Both surfaces are there because both are the right answer to a different question, but a loop over a million rows should be reading a column.

## How it binds

The Foreign Function and Memory API (Panama) is the primary path, with `jextract` generating the bindings from `zu.h` and `MemorySegment` giving genuinely zero-copy column access. There is no hand-written JNI shim on that path and no native code beyond `libzu` itself.
The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself.

An SDK that requires a recent JDK in 2026 excludes a large part of the enterprise ecosystem, so there is a JNI provider too:

| Artifact | Baseline | Role |
|---|---|---|
| `dev.zudb:zudb` | Java 17 | the API, no native code, no FFM types in the public surface |
| `dev.zudb:zudb-ffm` | Java 22+ | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17+ | the fallback provider |
| `dev.zudb:zudb-ffm` | Java 25 | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17 | the fallback provider |
| `dev.zudb:zudb-native-{platform}` | | the `libzu` binaries |

A `ServiceLoader` picks the provider at runtime and logs the choice once at debug level. Application code never names one. Baseline for the modern artifact is **Java 25 LTS**, CI runs 17, 21, 25, and 26.
A `ServiceLoader` picks the provider at run time and application code never names one. The FFM artifact targets Java 25 rather than the Java 22 that finalised the API, because 22 has been out of support since September 2024 and shipping against an unsupported release only moves the problem. CI runs 17, 21, 25, and 26.

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the class path case, the module path case wants `--enable-native-access=dev.zudb.ffm`, and the provider checks `Module::isNativeAccessEnabled` before the first downcall so that the failure is an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

## Errors

Every failure is a `ZuException`, and the subclass is chosen from the GQLSTATUS class rather than from the message: `ZuSyntaxException` for 42, `ZuDataException` for 22, `ZuTransactionException` for 25 and 40, and so on down. The exception carries the whole diagnostic, so a caller reads fields instead of parsing prose:

```java
catch (ZuSyntaxException e) {
e.code(); // the GQLSTATUS, for example 42001
e.condition(); // its standard text
e.position(); // line, column and byte offset, when there is one
e.caret().ifPresent(System.err::println);
e.retryable(); // whether running it again could work
}
```

## What works today

The engine has no DDL yet, so there is no `CREATE NODE TABLE` and nothing in this client writes a schema. What runs against a fresh database is the expression and projection surface: `RETURN`, `UNWIND`, parameters, lists, records, and the temporal types. The example at the top of this file describes the intended shape and needs a graph that some other tool built.

## Building

```sh
mvn test -Dzu.library=/path/to/libzu.dylib
```

The provider looks at `-Dzu.library`, then `ZU_LIBRARY`, then the platform library path. The tests skip rather than fail when no `libzu` is reachable, so a checkout with no engine build beside it is still green.

The benchmarks are JMH and are not published:

```sh
mvn package -DskipTests
ZU_LIBRARY=/path/to/libzu.dylib java -jar zudb-bench/target/benchmarks.jar
```

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the classpath case, the docs give the exact `--enable-native-access=dev.zudb` flag for the module path, and the binding detects the ungranted state at `Database.open` and throws a message containing the flag you need. A JVM warning on stderr three frames from any of our code is not a diagnosis anyone can act on.
`ZU_LIBRARY` rather than `-Dzu.library` there, because JMH forks a JVM of its own and a fork inherits the environment rather than the system properties.

## Beyond Java

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
123 changes: 123 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
MAVEN_ARGS: -B -ntp

jobs:
# The API artifact is what a caller compiles against, it has no native
# code in it, and it is the one thing that has to build on every JDK
# this client claims to support. It needs no engine, so it answers in
# under a minute and it answers first.
api:
strategy:
fail-fast: false
matrix:
java: ["17", "21", "25", "26-ea"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

# Only the API module, because the FFM provider compiles to release
# 25 and a JDK 17 compiler cannot be asked for that. A caller on 17
# gets exactly this artifact and the JNI provider beside it.
- run: mvn $MAVEN_ARGS -pl zudb -am test

# The whole client against the engine at its own HEAD, which is what
# makes a red job here mean the binding is wrong about the ABI rather
# than that a checked-in copy of something is stale.
engine:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
java: ["25", "26-ea"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5

- uses: actions/checkout@v5
with:
repository: tamnd/zu
path: engine

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

- uses: Swatinem/rust-cache@v2
with:
workspaces: engine

# ZU_ABI_VERSION is a header macro rather than a symbol, so a
# binding with no C compile step has nowhere to read it from and
# has to write it down. This is the step that stops the written
# down copy from drifting.
- name: The ABI this client speaks is the ABI the engine offers
run: |
set -eu
engine_abi="$(sed -n 's/^#define ZU_ABI_VERSION "\(.*\)"$/\1/p' \
engine/crates/zu-capi/include/zu.h)"
client_abi="$(sed -n 's/.*ABI_VERSION = "\(.*\)";.*/\1/p' \
zudb/src/main/java/dev/zudb/Zu.java)"
test -n "$engine_abi"
test -n "$client_abi"
echo "engine $engine_abi, client $client_abi"
test "$engine_abi" = "$client_abi"

- name: Build libzu
working-directory: engine
run: cargo build --release -p zu-capi

- name: Where the library landed
run: |
set -eu
lib="$(ls engine/target/release/libzu.dylib engine/target/release/libzu.so 2>/dev/null | head -1)"
test -n "$lib"
echo "ZU_LIBRARY=$GITHUB_WORKSPACE/$lib" >> "$GITHUB_ENV"

- run: mvn $MAVEN_ARGS test

# The suite again with assertions on everywhere, including the ones
# in the JDK itself. The bounds checks a MemorySegment does are the
# difference between a wrong offset failing and a wrong offset
# reading somebody else's memory.
- run: mvn $MAVEN_ARGS test -Dzu.test.args="-ea -esa"

# Not for the numbers, which mean nothing on a shared runner, but
# because a benchmark is code that nothing else compiles and
# nothing else runs. One iteration is enough to say it still works.
- run: mvn $MAVEN_ARGS -DskipTests package

- run: java -jar zudb-bench/target/benchmarks.jar -f 1 -wi 1 -i 1 -r 1s -w 1s

# What Maven Central will run over the artifacts, run here instead so
# that a release is not the first time anyone sees it.
javadoc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "25"
cache: maven

- run: mvn $MAVEN_ARGS -P release -DskipTests package
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
.DS_Store
target/

85 changes: 77 additions & 8 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,9 +8,6 @@ import dev.zudb.*;
try (Database db = Database.open("social.zu1");
Connection conn = db.connect()) {

conn.execute("CREATE NODE TABLE Person(id INT64 PRIMARY KEY, name STRING)");
conn.loadCsv("Person", Path.of("people.csv"));

try (Result result = conn.query("""
MATCH (p:Person)-[:Follows]->(f)
RETURN p.name AS name, count(*) AS n ORDER BY n DESC LIMIT 5
Expand All@@ -28,26 +25,98 @@ try (Database db = Database.open("social.zu1");
<artifactId>zudb</artifactId>
<version>${zu.version}</version>
</dependency>
<dependency>
<groupId>dev.zudb</groupId>
<artifactId>zudb-ffm</artifactId>
<version>${zu.version}</version>
<scope>runtime</scope>
</dependency>
```

Text blocks for queries, try-with-resources for every handle, `Stream<Row>` for iteration. Nothing here should surprise a Java developer, which is the whole goal.

## Reading a column without reading a row

A row at a time is the shape most callers want, and it is not the shape that makes an embedded database worth embedding. Every column of a result is also readable as one borrowed buffer over the engine's own memory, with no copy and no per-row call:

```java
try (Result r = conn.query("MATCH (p:Person) RETURN p.age")) {
LongBuffer ages = r.longs(0);
ByteBuffer valid = r.valid(0);

long total = 0;
for (int i = 0; i < ages.remaining(); i++) {
if (valid.get(i) != 0) {
total += ages.get(i);
}
}
}
```

The buffers are read-only views in native byte order, and they are valid until the `Result` closes. A result larger than one chunk is readable a chunk at a time through `r.chunks()`, which is the path that does not need the whole column resident. `java.nio` rather than `MemorySegment` on purpose: a Java 17 caller can name a `LongBuffer`, and both providers can hand one back without copying.

What it is worth, summing one integer column of a hundred thousand rows on an M-series laptop, JDK 25:

| How | Per row |
|---|---|
| `r.longs(0)` and a loop over the buffer | 0.45 ns |
| the same a chunk at a time | 4.1 ns |
| `for (Row row : r) row.getLong(0)` | 45 ns |
| `r.stream().mapToLong(...)` | 67 ns |

A row at a time is a boundary crossing a cell, and a hundred crossings cost about what one borrowed buffer costs. Both surfaces are there because both are the right answer to a different question, but a loop over a million rows should be reading a column.

## How it binds

The Foreign Function and Memory API (Panama) is the primary path, with `jextract` generating the bindings from `zu.h` and `MemorySegment` giving genuinely zero-copy column access. There is no hand-written JNI shim on that path and no native code beyond `libzu` itself.
The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself.

An SDK that requires a recent JDK in 2026 excludes a large part of the enterprise ecosystem, so there is a JNI provider too:

| Artifact | Baseline | Role |
|---|---|---|
| `dev.zudb:zudb` | Java 17 | the API, no native code, no FFM types in the public surface |
| `dev.zudb:zudb-ffm` | Java 22+ | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17+ | the fallback provider |
| `dev.zudb:zudb-ffm` | Java 25 | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17 | the fallback provider |
| `dev.zudb:zudb-native-{platform}` | | the `libzu` binaries |

A `ServiceLoader` picks the provider at runtime and logs the choice once at debug level. Application code never names one. Baseline for the modern artifact is **Java 25 LTS**, CI runs 17, 21, 25, and 26.
A `ServiceLoader` picks the provider at run time and application code never names one. The FFM artifact targets Java 25 rather than the Java 22 that finalised the API, because 22 has been out of support since September 2024 and shipping against an unsupported release only moves the problem. CI runs 17, 21, 25, and 26.

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the class path case, the module path case wants `--enable-native-access=dev.zudb.ffm`, and the provider checks `Module::isNativeAccessEnabled` before the first downcall so that the failure is an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

## Errors

Every failure is a `ZuException`, and the subclass is chosen from the GQLSTATUS class rather than from the message: `ZuSyntaxException` for 42, `ZuDataException` for 22, `ZuTransactionException` for 25 and 40, and so on down. The exception carries the whole diagnostic, so a caller reads fields instead of parsing prose:

```java
catch (ZuSyntaxException e) {
e.code(); // the GQLSTATUS, for example 42001
e.condition(); // its standard text
e.position(); // line, column and byte offset, when there is one
e.caret().ifPresent(System.err::println);
e.retryable(); // whether running it again could work
}
```

## What works today

The engine has no DDL yet, so there is no `CREATE NODE TABLE` and nothing in this client writes a schema. What runs against a fresh database is the expression and projection surface: `RETURN`, `UNWIND`, parameters, lists, records, and the temporal types. The example at the top of this file describes the intended shape and needs a graph that some other tool built.

## Building

```sh
mvn test -Dzu.library=/path/to/libzu.dylib
```

The provider looks at `-Dzu.library`, then `ZU_LIBRARY`, then the platform library path. The tests skip rather than fail when no `libzu` is reachable, so a checkout with no engine build beside it is still green.

The benchmarks are JMH and are not published:

```sh
mvn package -DskipTests
ZU_LIBRARY=/path/to/libzu.dylib java -jar zudb-bench/target/benchmarks.jar
```

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the classpath case, the docs give the exact `--enable-native-access=dev.zudb` flag for the module path, and the binding detects the ungranted state at `Database.open` and throws a message containing the flag you need. A JVM warning on stderr three frames from any of our code is not a diagnosis anyone can act on.
`ZU_LIBRARY` rather than `-Dzu.library` there, because JMH forks a JVM of its own and a fork inherits the environment rather than the system properties.

## Beyond Java

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
123 changes: 123 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
MAVEN_ARGS: -B -ntp

jobs:
# The API artifact is what a caller compiles against, it has no native
# code in it, and it is the one thing that has to build on every JDK
# this client claims to support. It needs no engine, so it answers in
# under a minute and it answers first.
api:
strategy:
fail-fast: false
matrix:
java: ["17", "21", "25", "26-ea"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

# Only the API module, because the FFM provider compiles to release
# 25 and a JDK 17 compiler cannot be asked for that. A caller on 17
# gets exactly this artifact and the JNI provider beside it.
- run: mvn $MAVEN_ARGS -pl zudb -am test

# The whole client against the engine at its own HEAD, which is what
# makes a red job here mean the binding is wrong about the ABI rather
# than that a checked-in copy of something is stale.
engine:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
java: ["25", "26-ea"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5

- uses: actions/checkout@v5
with:
repository: tamnd/zu
path: engine

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

- uses: Swatinem/rust-cache@v2
with:
workspaces: engine

# ZU_ABI_VERSION is a header macro rather than a symbol, so a
# binding with no C compile step has nowhere to read it from and
# has to write it down. This is the step that stops the written
# down copy from drifting.
- name: The ABI this client speaks is the ABI the engine offers
run: |
set -eu
engine_abi="$(sed -n 's/^#define ZU_ABI_VERSION "\(.*\)"$/\1/p' \
engine/crates/zu-capi/include/zu.h)"
client_abi="$(sed -n 's/.*ABI_VERSION = "\(.*\)";.*/\1/p' \
zudb/src/main/java/dev/zudb/Zu.java)"
test -n "$engine_abi"
test -n "$client_abi"
echo "engine $engine_abi, client $client_abi"
test "$engine_abi" = "$client_abi"

- name: Build libzu
working-directory: engine
run: cargo build --release -p zu-capi

- name: Where the library landed
run: |
set -eu
lib="$(ls engine/target/release/libzu.dylib engine/target/release/libzu.so 2>/dev/null | head -1)"
test -n "$lib"
echo "ZU_LIBRARY=$GITHUB_WORKSPACE/$lib" >> "$GITHUB_ENV"

- run: mvn $MAVEN_ARGS test

# The suite again with assertions on everywhere, including the ones
# in the JDK itself. The bounds checks a MemorySegment does are the
# difference between a wrong offset failing and a wrong offset
# reading somebody else's memory.
- run: mvn $MAVEN_ARGS test -Dzu.test.args="-ea -esa"

# Not for the numbers, which mean nothing on a shared runner, but
# because a benchmark is code that nothing else compiles and
# nothing else runs. One iteration is enough to say it still works.
- run: mvn $MAVEN_ARGS -DskipTests package

- run: java -jar zudb-bench/target/benchmarks.jar -f 1 -wi 1 -i 1 -r 1s -w 1s

# What Maven Central will run over the artifacts, run here instead so
# that a release is not the first time anyone sees it.
javadoc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "25"
cache: maven

- run: mvn $MAVEN_ARGS -P release -DskipTests package
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
.DS_Store
target/

85 changes: 77 additions & 8 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,9 +8,6 @@ import dev.zudb.*;
try (Database db = Database.open("social.zu1");
Connection conn = db.connect()) {

conn.execute("CREATE NODE TABLE Person(id INT64 PRIMARY KEY, name STRING)");
conn.loadCsv("Person", Path.of("people.csv"));

try (Result result = conn.query("""
MATCH (p:Person)-[:Follows]->(f)
RETURN p.name AS name, count(*) AS n ORDER BY n DESC LIMIT 5
Expand All@@ -28,26 +25,98 @@ try (Database db = Database.open("social.zu1");
<artifactId>zudb</artifactId>
<version>${zu.version}</version>
</dependency>
<dependency>
<groupId>dev.zudb</groupId>
<artifactId>zudb-ffm</artifactId>
<version>${zu.version}</version>
<scope>runtime</scope>
</dependency>
```

Text blocks for queries, try-with-resources for every handle, `Stream<Row>` for iteration. Nothing here should surprise a Java developer, which is the whole goal.

## Reading a column without reading a row

A row at a time is the shape most callers want, and it is not the shape that makes an embedded database worth embedding. Every column of a result is also readable as one borrowed buffer over the engine's own memory, with no copy and no per-row call:

```java
try (Result r = conn.query("MATCH (p:Person) RETURN p.age")) {
LongBuffer ages = r.longs(0);
ByteBuffer valid = r.valid(0);

long total = 0;
for (int i = 0; i < ages.remaining(); i++) {
if (valid.get(i) != 0) {
total += ages.get(i);
}
}
}
```

The buffers are read-only views in native byte order, and they are valid until the `Result` closes. A result larger than one chunk is readable a chunk at a time through `r.chunks()`, which is the path that does not need the whole column resident. `java.nio` rather than `MemorySegment` on purpose: a Java 17 caller can name a `LongBuffer`, and both providers can hand one back without copying.

What it is worth, summing one integer column of a hundred thousand rows on an M-series laptop, JDK 25:

| How | Per row |
|---|---|
| `r.longs(0)` and a loop over the buffer | 0.45 ns |
| the same a chunk at a time | 4.1 ns |
| `for (Row row : r) row.getLong(0)` | 45 ns |
| `r.stream().mapToLong(...)` | 67 ns |

A row at a time is a boundary crossing a cell, and a hundred crossings cost about what one borrowed buffer costs. Both surfaces are there because both are the right answer to a different question, but a loop over a million rows should be reading a column.

## How it binds

The Foreign Function and Memory API (Panama) is the primary path, with `jextract` generating the bindings from `zu.h` and `MemorySegment` giving genuinely zero-copy column access. There is no hand-written JNI shim on that path and no native code beyond `libzu` itself.
The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself.

An SDK that requires a recent JDK in 2026 excludes a large part of the enterprise ecosystem, so there is a JNI provider too:

| Artifact | Baseline | Role |
|---|---|---|
| `dev.zudb:zudb` | Java 17 | the API, no native code, no FFM types in the public surface |
| `dev.zudb:zudb-ffm` | Java 22+ | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17+ | the fallback provider |
| `dev.zudb:zudb-ffm` | Java 25 | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17 | the fallback provider |
| `dev.zudb:zudb-native-{platform}` | | the `libzu` binaries |

A `ServiceLoader` picks the provider at runtime and logs the choice once at debug level. Application code never names one. Baseline for the modern artifact is **Java 25 LTS**, CI runs 17, 21, 25, and 26.
A `ServiceLoader` picks the provider at run time and application code never names one. The FFM artifact targets Java 25 rather than the Java 22 that finalised the API, because 22 has been out of support since September 2024 and shipping against an unsupported release only moves the problem. CI runs 17, 21, 25, and 26.

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the class path case, the module path case wants `--enable-native-access=dev.zudb.ffm`, and the provider checks `Module::isNativeAccessEnabled` before the first downcall so that the failure is an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

## Errors

Every failure is a `ZuException`, and the subclass is chosen from the GQLSTATUS class rather than from the message: `ZuSyntaxException` for 42, `ZuDataException` for 22, `ZuTransactionException` for 25 and 40, and so on down. The exception carries the whole diagnostic, so a caller reads fields instead of parsing prose:

```java
catch (ZuSyntaxException e) {
e.code(); // the GQLSTATUS, for example 42001
e.condition(); // its standard text
e.position(); // line, column and byte offset, when there is one
e.caret().ifPresent(System.err::println);
e.retryable(); // whether running it again could work
}
```

## What works today

The engine has no DDL yet, so there is no `CREATE NODE TABLE` and nothing in this client writes a schema. What runs against a fresh database is the expression and projection surface: `RETURN`, `UNWIND`, parameters, lists, records, and the temporal types. The example at the top of this file describes the intended shape and needs a graph that some other tool built.

## Building

```sh
mvn test -Dzu.library=/path/to/libzu.dylib
```

The provider looks at `-Dzu.library`, then `ZU_LIBRARY`, then the platform library path. The tests skip rather than fail when no `libzu` is reachable, so a checkout with no engine build beside it is still green.

The benchmarks are JMH and are not published:

```sh
mvn package -DskipTests
ZU_LIBRARY=/path/to/libzu.dylib java -jar zudb-bench/target/benchmarks.jar
```

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the classpath case, the docs give the exact `--enable-native-access=dev.zudb` flag for the module path, and the binding detects the ungranted state at `Database.open` and throws a message containing the flag you need. A JVM warning on stderr three frames from any of our code is not a diagnosis anyone can act on.
`ZU_LIBRARY` rather than `-Dzu.library` there, because JMH forks a JVM of its own and a fork inherits the environment rather than the system properties.

## Beyond Java

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
123 changes: 123 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
MAVEN_ARGS: -B -ntp

jobs:
# The API artifact is what a caller compiles against, it has no native
# code in it, and it is the one thing that has to build on every JDK
# this client claims to support. It needs no engine, so it answers in
# under a minute and it answers first.
api:
strategy:
fail-fast: false
matrix:
java: ["17", "21", "25", "26-ea"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

# Only the API module, because the FFM provider compiles to release
# 25 and a JDK 17 compiler cannot be asked for that. A caller on 17
# gets exactly this artifact and the JNI provider beside it.
- run: mvn $MAVEN_ARGS -pl zudb -am test

# The whole client against the engine at its own HEAD, which is what
# makes a red job here mean the binding is wrong about the ABI rather
# than that a checked-in copy of something is stale.
engine:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
java: ["25", "26-ea"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5

- uses: actions/checkout@v5
with:
repository: tamnd/zu
path: engine

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

- uses: Swatinem/rust-cache@v2
with:
workspaces: engine

# ZU_ABI_VERSION is a header macro rather than a symbol, so a
# binding with no C compile step has nowhere to read it from and
# has to write it down. This is the step that stops the written
# down copy from drifting.
- name: The ABI this client speaks is the ABI the engine offers
run: |
set -eu
engine_abi="$(sed -n 's/^#define ZU_ABI_VERSION "\(.*\)"$/\1/p' \
engine/crates/zu-capi/include/zu.h)"
client_abi="$(sed -n 's/.*ABI_VERSION = "\(.*\)";.*/\1/p' \
zudb/src/main/java/dev/zudb/Zu.java)"
test -n "$engine_abi"
test -n "$client_abi"
echo "engine $engine_abi, client $client_abi"
test "$engine_abi" = "$client_abi"

- name: Build libzu
working-directory: engine
run: cargo build --release -p zu-capi

- name: Where the library landed
run: |
set -eu
lib="$(ls engine/target/release/libzu.dylib engine/target/release/libzu.so 2>/dev/null | head -1)"
test -n "$lib"
echo "ZU_LIBRARY=$GITHUB_WORKSPACE/$lib" >> "$GITHUB_ENV"

- run: mvn $MAVEN_ARGS test

# The suite again with assertions on everywhere, including the ones
# in the JDK itself. The bounds checks a MemorySegment does are the
# difference between a wrong offset failing and a wrong offset
# reading somebody else's memory.
- run: mvn $MAVEN_ARGS test -Dzu.test.args="-ea -esa"

# Not for the numbers, which mean nothing on a shared runner, but
# because a benchmark is code that nothing else compiles and
# nothing else runs. One iteration is enough to say it still works.
- run: mvn $MAVEN_ARGS -DskipTests package

- run: java -jar zudb-bench/target/benchmarks.jar -f 1 -wi 1 -i 1 -r 1s -w 1s

# What Maven Central will run over the artifacts, run here instead so
# that a release is not the first time anyone sees it.
javadoc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "25"
cache: maven

- run: mvn $MAVEN_ARGS -P release -DskipTests package
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
.DS_Store
target/

85 changes: 77 additions & 8 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,9 +8,6 @@ import dev.zudb.*;
try (Database db = Database.open("social.zu1");
Connection conn = db.connect()) {

conn.execute("CREATE NODE TABLE Person(id INT64 PRIMARY KEY, name STRING)");
conn.loadCsv("Person", Path.of("people.csv"));

try (Result result = conn.query("""
MATCH (p:Person)-[:Follows]->(f)
RETURN p.name AS name, count(*) AS n ORDER BY n DESC LIMIT 5
Expand All@@ -28,26 +25,98 @@ try (Database db = Database.open("social.zu1");
<artifactId>zudb</artifactId>
<version>${zu.version}</version>
</dependency>
<dependency>
<groupId>dev.zudb</groupId>
<artifactId>zudb-ffm</artifactId>
<version>${zu.version}</version>
<scope>runtime</scope>
</dependency>
```

Text blocks for queries, try-with-resources for every handle, `Stream<Row>` for iteration. Nothing here should surprise a Java developer, which is the whole goal.

## Reading a column without reading a row

A row at a time is the shape most callers want, and it is not the shape that makes an embedded database worth embedding. Every column of a result is also readable as one borrowed buffer over the engine's own memory, with no copy and no per-row call:

```java
try (Result r = conn.query("MATCH (p:Person) RETURN p.age")) {
LongBuffer ages = r.longs(0);
ByteBuffer valid = r.valid(0);

long total = 0;
for (int i = 0; i < ages.remaining(); i++) {
if (valid.get(i) != 0) {
total += ages.get(i);
}
}
}
```

The buffers are read-only views in native byte order, and they are valid until the `Result` closes. A result larger than one chunk is readable a chunk at a time through `r.chunks()`, which is the path that does not need the whole column resident. `java.nio` rather than `MemorySegment` on purpose: a Java 17 caller can name a `LongBuffer`, and both providers can hand one back without copying.

What it is worth, summing one integer column of a hundred thousand rows on an M-series laptop, JDK 25:

| How | Per row |
|---|---|
| `r.longs(0)` and a loop over the buffer | 0.45 ns |
| the same a chunk at a time | 4.1 ns |
| `for (Row row : r) row.getLong(0)` | 45 ns |
| `r.stream().mapToLong(...)` | 67 ns |

A row at a time is a boundary crossing a cell, and a hundred crossings cost about what one borrowed buffer costs. Both surfaces are there because both are the right answer to a different question, but a loop over a million rows should be reading a column.

## How it binds

The Foreign Function and Memory API (Panama) is the primary path, with `jextract` generating the bindings from `zu.h` and `MemorySegment` giving genuinely zero-copy column access. There is no hand-written JNI shim on that path and no native code beyond `libzu` itself.
The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself.

An SDK that requires a recent JDK in 2026 excludes a large part of the enterprise ecosystem, so there is a JNI provider too:

| Artifact | Baseline | Role |
|---|---|---|
| `dev.zudb:zudb` | Java 17 | the API, no native code, no FFM types in the public surface |
| `dev.zudb:zudb-ffm` | Java 22+ | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17+ | the fallback provider |
| `dev.zudb:zudb-ffm` | Java 25 | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17 | the fallback provider |
| `dev.zudb:zudb-native-{platform}` | | the `libzu` binaries |

A `ServiceLoader` picks the provider at runtime and logs the choice once at debug level. Application code never names one. Baseline for the modern artifact is **Java 25 LTS**, CI runs 17, 21, 25, and 26.
A `ServiceLoader` picks the provider at run time and application code never names one. The FFM artifact targets Java 25 rather than the Java 22 that finalised the API, because 22 has been out of support since September 2024 and shipping against an unsupported release only moves the problem. CI runs 17, 21, 25, and 26.

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the class path case, the module path case wants `--enable-native-access=dev.zudb.ffm`, and the provider checks `Module::isNativeAccessEnabled` before the first downcall so that the failure is an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

## Errors

Every failure is a `ZuException`, and the subclass is chosen from the GQLSTATUS class rather than from the message: `ZuSyntaxException` for 42, `ZuDataException` for 22, `ZuTransactionException` for 25 and 40, and so on down. The exception carries the whole diagnostic, so a caller reads fields instead of parsing prose:

```java
catch (ZuSyntaxException e) {
e.code(); // the GQLSTATUS, for example 42001
e.condition(); // its standard text
e.position(); // line, column and byte offset, when there is one
e.caret().ifPresent(System.err::println);
e.retryable(); // whether running it again could work
}
```

## What works today

The engine has no DDL yet, so there is no `CREATE NODE TABLE` and nothing in this client writes a schema. What runs against a fresh database is the expression and projection surface: `RETURN`, `UNWIND`, parameters, lists, records, and the temporal types. The example at the top of this file describes the intended shape and needs a graph that some other tool built.

## Building

```sh
mvn test -Dzu.library=/path/to/libzu.dylib
```

The provider looks at `-Dzu.library`, then `ZU_LIBRARY`, then the platform library path. The tests skip rather than fail when no `libzu` is reachable, so a checkout with no engine build beside it is still green.

The benchmarks are JMH and are not published:

```sh
mvn package -DskipTests
ZU_LIBRARY=/path/to/libzu.dylib java -jar zudb-bench/target/benchmarks.jar
```

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the classpath case, the docs give the exact `--enable-native-access=dev.zudb` flag for the module path, and the binding detects the ungranted state at `Database.open` and throws a message containing the flag you need. A JVM warning on stderr three frames from any of our code is not a diagnosis anyone can act on.
`ZU_LIBRARY` rather than `-Dzu.library` there, because JMH forks a JVM of its own and a fork inherits the environment rather than the system properties.

## Beyond Java

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
123 changes: 123 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
MAVEN_ARGS: -B -ntp

jobs:
# The API artifact is what a caller compiles against, it has no native
# code in it, and it is the one thing that has to build on every JDK
# this client claims to support. It needs no engine, so it answers in
# under a minute and it answers first.
api:
strategy:
fail-fast: false
matrix:
java: ["17", "21", "25", "26-ea"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

# Only the API module, because the FFM provider compiles to release
# 25 and a JDK 17 compiler cannot be asked for that. A caller on 17
# gets exactly this artifact and the JNI provider beside it.
- run: mvn $MAVEN_ARGS -pl zudb -am test

# The whole client against the engine at its own HEAD, which is what
# makes a red job here mean the binding is wrong about the ABI rather
# than that a checked-in copy of something is stale.
engine:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
java: ["25", "26-ea"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5

- uses: actions/checkout@v5
with:
repository: tamnd/zu
path: engine

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

- uses: Swatinem/rust-cache@v2
with:
workspaces: engine

# ZU_ABI_VERSION is a header macro rather than a symbol, so a
# binding with no C compile step has nowhere to read it from and
# has to write it down. This is the step that stops the written
# down copy from drifting.
- name: The ABI this client speaks is the ABI the engine offers
run: |
set -eu
engine_abi="$(sed -n 's/^#define ZU_ABI_VERSION "\(.*\)"$/\1/p' \
engine/crates/zu-capi/include/zu.h)"
client_abi="$(sed -n 's/.*ABI_VERSION = "\(.*\)";.*/\1/p' \
zudb/src/main/java/dev/zudb/Zu.java)"
test -n "$engine_abi"
test -n "$client_abi"
echo "engine $engine_abi, client $client_abi"
test "$engine_abi" = "$client_abi"

- name: Build libzu
working-directory: engine
run: cargo build --release -p zu-capi

- name: Where the library landed
run: |
set -eu
lib="$(ls engine/target/release/libzu.dylib engine/target/release/libzu.so 2>/dev/null | head -1)"
test -n "$lib"
echo "ZU_LIBRARY=$GITHUB_WORKSPACE/$lib" >> "$GITHUB_ENV"

- run: mvn $MAVEN_ARGS test

# The suite again with assertions on everywhere, including the ones
# in the JDK itself. The bounds checks a MemorySegment does are the
# difference between a wrong offset failing and a wrong offset
# reading somebody else's memory.
- run: mvn $MAVEN_ARGS test -Dzu.test.args="-ea -esa"

# Not for the numbers, which mean nothing on a shared runner, but
# because a benchmark is code that nothing else compiles and
# nothing else runs. One iteration is enough to say it still works.
- run: mvn $MAVEN_ARGS -DskipTests package

- run: java -jar zudb-bench/target/benchmarks.jar -f 1 -wi 1 -i 1 -r 1s -w 1s

# What Maven Central will run over the artifacts, run here instead so
# that a release is not the first time anyone sees it.
javadoc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "25"
cache: maven

- run: mvn $MAVEN_ARGS -P release -DskipTests package
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
.DS_Store
target/

85 changes: 77 additions & 8 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,9 +8,6 @@ import dev.zudb.*;
try (Database db = Database.open("social.zu1");
Connection conn = db.connect()) {

conn.execute("CREATE NODE TABLE Person(id INT64 PRIMARY KEY, name STRING)");
conn.loadCsv("Person", Path.of("people.csv"));

try (Result result = conn.query("""
MATCH (p:Person)-[:Follows]->(f)
RETURN p.name AS name, count(*) AS n ORDER BY n DESC LIMIT 5
Expand All@@ -28,26 +25,98 @@ try (Database db = Database.open("social.zu1");
<artifactId>zudb</artifactId>
<version>${zu.version}</version>
</dependency>
<dependency>
<groupId>dev.zudb</groupId>
<artifactId>zudb-ffm</artifactId>
<version>${zu.version}</version>
<scope>runtime</scope>
</dependency>
```

Text blocks for queries, try-with-resources for every handle, `Stream<Row>` for iteration. Nothing here should surprise a Java developer, which is the whole goal.

## Reading a column without reading a row

A row at a time is the shape most callers want, and it is not the shape that makes an embedded database worth embedding. Every column of a result is also readable as one borrowed buffer over the engine's own memory, with no copy and no per-row call:

```java
try (Result r = conn.query("MATCH (p:Person) RETURN p.age")) {
LongBuffer ages = r.longs(0);
ByteBuffer valid = r.valid(0);

long total = 0;
for (int i = 0; i < ages.remaining(); i++) {
if (valid.get(i) != 0) {
total += ages.get(i);
}
}
}
```

The buffers are read-only views in native byte order, and they are valid until the `Result` closes. A result larger than one chunk is readable a chunk at a time through `r.chunks()`, which is the path that does not need the whole column resident. `java.nio` rather than `MemorySegment` on purpose: a Java 17 caller can name a `LongBuffer`, and both providers can hand one back without copying.

What it is worth, summing one integer column of a hundred thousand rows on an M-series laptop, JDK 25:

| How | Per row |
|---|---|
| `r.longs(0)` and a loop over the buffer | 0.45 ns |
| the same a chunk at a time | 4.1 ns |
| `for (Row row : r) row.getLong(0)` | 45 ns |
| `r.stream().mapToLong(...)` | 67 ns |

A row at a time is a boundary crossing a cell, and a hundred crossings cost about what one borrowed buffer costs. Both surfaces are there because both are the right answer to a different question, but a loop over a million rows should be reading a column.

## How it binds

The Foreign Function and Memory API (Panama) is the primary path, with `jextract` generating the bindings from `zu.h` and `MemorySegment` giving genuinely zero-copy column access. There is no hand-written JNI shim on that path and no native code beyond `libzu` itself.
The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself.

An SDK that requires a recent JDK in 2026 excludes a large part of the enterprise ecosystem, so there is a JNI provider too:

| Artifact | Baseline | Role |
|---|---|---|
| `dev.zudb:zudb` | Java 17 | the API, no native code, no FFM types in the public surface |
| `dev.zudb:zudb-ffm` | Java 22+ | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17+ | the fallback provider |
| `dev.zudb:zudb-ffm` | Java 25 | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17 | the fallback provider |
| `dev.zudb:zudb-native-{platform}` | | the `libzu` binaries |

A `ServiceLoader` picks the provider at runtime and logs the choice once at debug level. Application code never names one. Baseline for the modern artifact is **Java 25 LTS**, CI runs 17, 21, 25, and 26.
A `ServiceLoader` picks the provider at run time and application code never names one. The FFM artifact targets Java 25 rather than the Java 22 that finalised the API, because 22 has been out of support since September 2024 and shipping against an unsupported release only moves the problem. CI runs 17, 21, 25, and 26.

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the class path case, the module path case wants `--enable-native-access=dev.zudb.ffm`, and the provider checks `Module::isNativeAccessEnabled` before the first downcall so that the failure is an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

## Errors

Every failure is a `ZuException`, and the subclass is chosen from the GQLSTATUS class rather than from the message: `ZuSyntaxException` for 42, `ZuDataException` for 22, `ZuTransactionException` for 25 and 40, and so on down. The exception carries the whole diagnostic, so a caller reads fields instead of parsing prose:

```java
catch (ZuSyntaxException e) {
e.code(); // the GQLSTATUS, for example 42001
e.condition(); // its standard text
e.position(); // line, column and byte offset, when there is one
e.caret().ifPresent(System.err::println);
e.retryable(); // whether running it again could work
}
```

## What works today

The engine has no DDL yet, so there is no `CREATE NODE TABLE` and nothing in this client writes a schema. What runs against a fresh database is the expression and projection surface: `RETURN`, `UNWIND`, parameters, lists, records, and the temporal types. The example at the top of this file describes the intended shape and needs a graph that some other tool built.

## Building

```sh
mvn test -Dzu.library=/path/to/libzu.dylib
```

The provider looks at `-Dzu.library`, then `ZU_LIBRARY`, then the platform library path. The tests skip rather than fail when no `libzu` is reachable, so a checkout with no engine build beside it is still green.

The benchmarks are JMH and are not published:

```sh
mvn package -DskipTests
ZU_LIBRARY=/path/to/libzu.dylib java -jar zudb-bench/target/benchmarks.jar
```

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the classpath case, the docs give the exact `--enable-native-access=dev.zudb` flag for the module path, and the binding detects the ungranted state at `Database.open` and throws a message containing the flag you need. A JVM warning on stderr three frames from any of our code is not a diagnosis anyone can act on.
`ZU_LIBRARY` rather than `-Dzu.library` there, because JMH forks a JVM of its own and a fork inherits the environment rather than the system properties.

## Beyond Java

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
123 changes: 123 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
MAVEN_ARGS: -B -ntp

jobs:
# The API artifact is what a caller compiles against, it has no native
# code in it, and it is the one thing that has to build on every JDK
# this client claims to support. It needs no engine, so it answers in
# under a minute and it answers first.
api:
strategy:
fail-fast: false
matrix:
java: ["17", "21", "25", "26-ea"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

# Only the API module, because the FFM provider compiles to release
# 25 and a JDK 17 compiler cannot be asked for that. A caller on 17
# gets exactly this artifact and the JNI provider beside it.
- run: mvn $MAVEN_ARGS -pl zudb -am test

# The whole client against the engine at its own HEAD, which is what
# makes a red job here mean the binding is wrong about the ABI rather
# than that a checked-in copy of something is stale.
engine:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
java: ["25", "26-ea"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5

- uses: actions/checkout@v5
with:
repository: tamnd/zu
path: engine

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

- uses: Swatinem/rust-cache@v2
with:
workspaces: engine

# ZU_ABI_VERSION is a header macro rather than a symbol, so a
# binding with no C compile step has nowhere to read it from and
# has to write it down. This is the step that stops the written
# down copy from drifting.
- name: The ABI this client speaks is the ABI the engine offers
run: |
set -eu
engine_abi="$(sed -n 's/^#define ZU_ABI_VERSION "\(.*\)"$/\1/p' \
engine/crates/zu-capi/include/zu.h)"
client_abi="$(sed -n 's/.*ABI_VERSION = "\(.*\)";.*/\1/p' \
zudb/src/main/java/dev/zudb/Zu.java)"
test -n "$engine_abi"
test -n "$client_abi"
echo "engine $engine_abi, client $client_abi"
test "$engine_abi" = "$client_abi"

- name: Build libzu
working-directory: engine
run: cargo build --release -p zu-capi

- name: Where the library landed
run: |
set -eu
lib="$(ls engine/target/release/libzu.dylib engine/target/release/libzu.so 2>/dev/null | head -1)"
test -n "$lib"
echo "ZU_LIBRARY=$GITHUB_WORKSPACE/$lib" >> "$GITHUB_ENV"

- run: mvn $MAVEN_ARGS test

# The suite again with assertions on everywhere, including the ones
# in the JDK itself. The bounds checks a MemorySegment does are the
# difference between a wrong offset failing and a wrong offset
# reading somebody else's memory.
- run: mvn $MAVEN_ARGS test -Dzu.test.args="-ea -esa"

# Not for the numbers, which mean nothing on a shared runner, but
# because a benchmark is code that nothing else compiles and
# nothing else runs. One iteration is enough to say it still works.
- run: mvn $MAVEN_ARGS -DskipTests package

- run: java -jar zudb-bench/target/benchmarks.jar -f 1 -wi 1 -i 1 -r 1s -w 1s

# What Maven Central will run over the artifacts, run here instead so
# that a release is not the first time anyone sees it.
javadoc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "25"
cache: maven

- run: mvn $MAVEN_ARGS -P release -DskipTests package
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
.DS_Store
target/

85 changes: 77 additions & 8 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,9 +8,6 @@ import dev.zudb.*;
try (Database db = Database.open("social.zu1");
Connection conn = db.connect()) {

conn.execute("CREATE NODE TABLE Person(id INT64 PRIMARY KEY, name STRING)");
conn.loadCsv("Person", Path.of("people.csv"));

try (Result result = conn.query("""
MATCH (p:Person)-[:Follows]->(f)
RETURN p.name AS name, count(*) AS n ORDER BY n DESC LIMIT 5
Expand All@@ -28,26 +25,98 @@ try (Database db = Database.open("social.zu1");
<artifactId>zudb</artifactId>
<version>${zu.version}</version>
</dependency>
<dependency>
<groupId>dev.zudb</groupId>
<artifactId>zudb-ffm</artifactId>
<version>${zu.version}</version>
<scope>runtime</scope>
</dependency>
```

Text blocks for queries, try-with-resources for every handle, `Stream<Row>` for iteration. Nothing here should surprise a Java developer, which is the whole goal.

## Reading a column without reading a row

A row at a time is the shape most callers want, and it is not the shape that makes an embedded database worth embedding. Every column of a result is also readable as one borrowed buffer over the engine's own memory, with no copy and no per-row call:

```java
try (Result r = conn.query("MATCH (p:Person) RETURN p.age")) {
LongBuffer ages = r.longs(0);
ByteBuffer valid = r.valid(0);

long total = 0;
for (int i = 0; i < ages.remaining(); i++) {
if (valid.get(i) != 0) {
total += ages.get(i);
}
}
}
```

The buffers are read-only views in native byte order, and they are valid until the `Result` closes. A result larger than one chunk is readable a chunk at a time through `r.chunks()`, which is the path that does not need the whole column resident. `java.nio` rather than `MemorySegment` on purpose: a Java 17 caller can name a `LongBuffer`, and both providers can hand one back without copying.

What it is worth, summing one integer column of a hundred thousand rows on an M-series laptop, JDK 25:

| How | Per row |
|---|---|
| `r.longs(0)` and a loop over the buffer | 0.45 ns |
| the same a chunk at a time | 4.1 ns |
| `for (Row row : r) row.getLong(0)` | 45 ns |
| `r.stream().mapToLong(...)` | 67 ns |

A row at a time is a boundary crossing a cell, and a hundred crossings cost about what one borrowed buffer costs. Both surfaces are there because both are the right answer to a different question, but a loop over a million rows should be reading a column.

## How it binds

The Foreign Function and Memory API (Panama) is the primary path, with `jextract` generating the bindings from `zu.h` and `MemorySegment` giving genuinely zero-copy column access. There is no hand-written JNI shim on that path and no native code beyond `libzu` itself.
The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself.

An SDK that requires a recent JDK in 2026 excludes a large part of the enterprise ecosystem, so there is a JNI provider too:

| Artifact | Baseline | Role |
|---|---|---|
| `dev.zudb:zudb` | Java 17 | the API, no native code, no FFM types in the public surface |
| `dev.zudb:zudb-ffm` | Java 22+ | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17+ | the fallback provider |
| `dev.zudb:zudb-ffm` | Java 25 | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17 | the fallback provider |
| `dev.zudb:zudb-native-{platform}` | | the `libzu` binaries |

A `ServiceLoader` picks the provider at runtime and logs the choice once at debug level. Application code never names one. Baseline for the modern artifact is **Java 25 LTS**, CI runs 17, 21, 25, and 26.
A `ServiceLoader` picks the provider at run time and application code never names one. The FFM artifact targets Java 25 rather than the Java 22 that finalised the API, because 22 has been out of support since September 2024 and shipping against an unsupported release only moves the problem. CI runs 17, 21, 25, and 26.

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the class path case, the module path case wants `--enable-native-access=dev.zudb.ffm`, and the provider checks `Module::isNativeAccessEnabled` before the first downcall so that the failure is an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

## Errors

Every failure is a `ZuException`, and the subclass is chosen from the GQLSTATUS class rather than from the message: `ZuSyntaxException` for 42, `ZuDataException` for 22, `ZuTransactionException` for 25 and 40, and so on down. The exception carries the whole diagnostic, so a caller reads fields instead of parsing prose:

```java
catch (ZuSyntaxException e) {
e.code(); // the GQLSTATUS, for example 42001
e.condition(); // its standard text
e.position(); // line, column and byte offset, when there is one
e.caret().ifPresent(System.err::println);
e.retryable(); // whether running it again could work
}
```

## What works today

The engine has no DDL yet, so there is no `CREATE NODE TABLE` and nothing in this client writes a schema. What runs against a fresh database is the expression and projection surface: `RETURN`, `UNWIND`, parameters, lists, records, and the temporal types. The example at the top of this file describes the intended shape and needs a graph that some other tool built.

## Building

```sh
mvn test -Dzu.library=/path/to/libzu.dylib
```

The provider looks at `-Dzu.library`, then `ZU_LIBRARY`, then the platform library path. The tests skip rather than fail when no `libzu` is reachable, so a checkout with no engine build beside it is still green.

The benchmarks are JMH and are not published:

```sh
mvn package -DskipTests
ZU_LIBRARY=/path/to/libzu.dylib java -jar zudb-bench/target/benchmarks.jar
```

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the classpath case, the docs give the exact `--enable-native-access=dev.zudb` flag for the module path, and the binding detects the ungranted state at `Database.open` and throws a message containing the flag you need. A JVM warning on stderr three frames from any of our code is not a diagnosis anyone can act on.
`ZU_LIBRARY` rather than `-Dzu.library` there, because JMH forks a JVM of its own and a fork inherits the environment rather than the system properties.

## Beyond Java

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
123 changes: 123 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
MAVEN_ARGS: -B -ntp

jobs:
# The API artifact is what a caller compiles against, it has no native
# code in it, and it is the one thing that has to build on every JDK
# this client claims to support. It needs no engine, so it answers in
# under a minute and it answers first.
api:
strategy:
fail-fast: false
matrix:
java: ["17", "21", "25", "26-ea"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

# Only the API module, because the FFM provider compiles to release
# 25 and a JDK 17 compiler cannot be asked for that. A caller on 17
# gets exactly this artifact and the JNI provider beside it.
- run: mvn $MAVEN_ARGS -pl zudb -am test

# The whole client against the engine at its own HEAD, which is what
# makes a red job here mean the binding is wrong about the ABI rather
# than that a checked-in copy of something is stale.
engine:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
java: ["25", "26-ea"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5

- uses: actions/checkout@v5
with:
repository: tamnd/zu
path: engine

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

- uses: Swatinem/rust-cache@v2
with:
workspaces: engine

# ZU_ABI_VERSION is a header macro rather than a symbol, so a
# binding with no C compile step has nowhere to read it from and
# has to write it down. This is the step that stops the written
# down copy from drifting.
- name: The ABI this client speaks is the ABI the engine offers
run: |
set -eu
engine_abi="$(sed -n 's/^#define ZU_ABI_VERSION "\(.*\)"$/\1/p' \
engine/crates/zu-capi/include/zu.h)"
client_abi="$(sed -n 's/.*ABI_VERSION = "\(.*\)";.*/\1/p' \
zudb/src/main/java/dev/zudb/Zu.java)"
test -n "$engine_abi"
test -n "$client_abi"
echo "engine $engine_abi, client $client_abi"
test "$engine_abi" = "$client_abi"

- name: Build libzu
working-directory: engine
run: cargo build --release -p zu-capi

- name: Where the library landed
run: |
set -eu
lib="$(ls engine/target/release/libzu.dylib engine/target/release/libzu.so 2>/dev/null | head -1)"
test -n "$lib"
echo "ZU_LIBRARY=$GITHUB_WORKSPACE/$lib" >> "$GITHUB_ENV"

- run: mvn $MAVEN_ARGS test

# The suite again with assertions on everywhere, including the ones
# in the JDK itself. The bounds checks a MemorySegment does are the
# difference between a wrong offset failing and a wrong offset
# reading somebody else's memory.
- run: mvn $MAVEN_ARGS test -Dzu.test.args="-ea -esa"

# Not for the numbers, which mean nothing on a shared runner, but
# because a benchmark is code that nothing else compiles and
# nothing else runs. One iteration is enough to say it still works.
- run: mvn $MAVEN_ARGS -DskipTests package

- run: java -jar zudb-bench/target/benchmarks.jar -f 1 -wi 1 -i 1 -r 1s -w 1s

# What Maven Central will run over the artifacts, run here instead so
# that a release is not the first time anyone sees it.
javadoc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "25"
cache: maven

- run: mvn $MAVEN_ARGS -P release -DskipTests package
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
.DS_Store
target/

85 changes: 77 additions & 8 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,9 +8,6 @@ import dev.zudb.*;
try (Database db = Database.open("social.zu1");
Connection conn = db.connect()) {

conn.execute("CREATE NODE TABLE Person(id INT64 PRIMARY KEY, name STRING)");
conn.loadCsv("Person", Path.of("people.csv"));

try (Result result = conn.query("""
MATCH (p:Person)-[:Follows]->(f)
RETURN p.name AS name, count(*) AS n ORDER BY n DESC LIMIT 5
Expand All@@ -28,26 +25,98 @@ try (Database db = Database.open("social.zu1");
<artifactId>zudb</artifactId>
<version>${zu.version}</version>
</dependency>
<dependency>
<groupId>dev.zudb</groupId>
<artifactId>zudb-ffm</artifactId>
<version>${zu.version}</version>
<scope>runtime</scope>
</dependency>
```

Text blocks for queries, try-with-resources for every handle, `Stream<Row>` for iteration. Nothing here should surprise a Java developer, which is the whole goal.

## Reading a column without reading a row

A row at a time is the shape most callers want, and it is not the shape that makes an embedded database worth embedding. Every column of a result is also readable as one borrowed buffer over the engine's own memory, with no copy and no per-row call:

```java
try (Result r = conn.query("MATCH (p:Person) RETURN p.age")) {
LongBuffer ages = r.longs(0);
ByteBuffer valid = r.valid(0);

long total = 0;
for (int i = 0; i < ages.remaining(); i++) {
if (valid.get(i) != 0) {
total += ages.get(i);
}
}
}
```

The buffers are read-only views in native byte order, and they are valid until the `Result` closes. A result larger than one chunk is readable a chunk at a time through `r.chunks()`, which is the path that does not need the whole column resident. `java.nio` rather than `MemorySegment` on purpose: a Java 17 caller can name a `LongBuffer`, and both providers can hand one back without copying.

What it is worth, summing one integer column of a hundred thousand rows on an M-series laptop, JDK 25:

| How | Per row |
|---|---|
| `r.longs(0)` and a loop over the buffer | 0.45 ns |
| the same a chunk at a time | 4.1 ns |
| `for (Row row : r) row.getLong(0)` | 45 ns |
| `r.stream().mapToLong(...)` | 67 ns |

A row at a time is a boundary crossing a cell, and a hundred crossings cost about what one borrowed buffer costs. Both surfaces are there because both are the right answer to a different question, but a loop over a million rows should be reading a column.

## How it binds

The Foreign Function and Memory API (Panama) is the primary path, with `jextract` generating the bindings from `zu.h` and `MemorySegment` giving genuinely zero-copy column access. There is no hand-written JNI shim on that path and no native code beyond `libzu` itself.
The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself.

An SDK that requires a recent JDK in 2026 excludes a large part of the enterprise ecosystem, so there is a JNI provider too:

| Artifact | Baseline | Role |
|---|---|---|
| `dev.zudb:zudb` | Java 17 | the API, no native code, no FFM types in the public surface |
| `dev.zudb:zudb-ffm` | Java 22+ | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17+ | the fallback provider |
| `dev.zudb:zudb-ffm` | Java 25 | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17 | the fallback provider |
| `dev.zudb:zudb-native-{platform}` | | the `libzu` binaries |

A `ServiceLoader` picks the provider at runtime and logs the choice once at debug level. Application code never names one. Baseline for the modern artifact is **Java 25 LTS**, CI runs 17, 21, 25, and 26.
A `ServiceLoader` picks the provider at run time and application code never names one. The FFM artifact targets Java 25 rather than the Java 22 that finalised the API, because 22 has been out of support since September 2024 and shipping against an unsupported release only moves the problem. CI runs 17, 21, 25, and 26.

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the class path case, the module path case wants `--enable-native-access=dev.zudb.ffm`, and the provider checks `Module::isNativeAccessEnabled` before the first downcall so that the failure is an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

## Errors

Every failure is a `ZuException`, and the subclass is chosen from the GQLSTATUS class rather than from the message: `ZuSyntaxException` for 42, `ZuDataException` for 22, `ZuTransactionException` for 25 and 40, and so on down. The exception carries the whole diagnostic, so a caller reads fields instead of parsing prose:

```java
catch (ZuSyntaxException e) {
e.code(); // the GQLSTATUS, for example 42001
e.condition(); // its standard text
e.position(); // line, column and byte offset, when there is one
e.caret().ifPresent(System.err::println);
e.retryable(); // whether running it again could work
}
```

## What works today

The engine has no DDL yet, so there is no `CREATE NODE TABLE` and nothing in this client writes a schema. What runs against a fresh database is the expression and projection surface: `RETURN`, `UNWIND`, parameters, lists, records, and the temporal types. The example at the top of this file describes the intended shape and needs a graph that some other tool built.

## Building

```sh
mvn test -Dzu.library=/path/to/libzu.dylib
```

The provider looks at `-Dzu.library`, then `ZU_LIBRARY`, then the platform library path. The tests skip rather than fail when no `libzu` is reachable, so a checkout with no engine build beside it is still green.

The benchmarks are JMH and are not published:

```sh
mvn package -DskipTests
ZU_LIBRARY=/path/to/libzu.dylib java -jar zudb-bench/target/benchmarks.jar
```

One thing to know before your first run: from JDK 24, native access must be granted explicitly. The jars carry `Enable-Native-Access: ALL-UNNAMED` for the classpath case, the docs give the exact `--enable-native-access=dev.zudb` flag for the module path, and the binding detects the ungranted state at `Database.open` and throws a message containing the flag you need. A JVM warning on stderr three frames from any of our code is not a diagnosis anyone can act on.
`ZU_LIBRARY` rather than `-Dzu.library` there, because JMH forks a JVM of its own and a fork inherits the environment rather than the system properties.

## Beyond Java

Expand Down
Loading
Loading