8000 feat(engine)!: ship one native reylo binary per platform by colinhacks · Pull Request #152 · colinhacks/reylo · GitHub
[go: up one dir, main page]

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
5 changes: 5 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# The SQLite that rusqlite bundles into every native build (the engine's program, the oracle) has
# its math, percentile and geopoly functions, so a statement that calls `ceiling` or `acos` reads as
# calling a function SQLite has, as it did in the WebAssembly engines.
[env]
LIBSQLITE3_FLAGS = "-DSQLITE_ENABLE_MATH_FUNCTIONS -DSQLITE_ENABLE_PERCENTILE -DSQLITE_ENABLE_GEOPOLY"
109 changes: 109 additions & 0 deletions .github/scripts/build-binary.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Builds the `reylo` binary for one platform of npm/platforms.json on the runner binaries.yml gives
# it, natively: each platform has a runner of its own architecture, and the two that are not native
# are made with the runner's own toolchain (macOS on Intel, from Apple silicon) or with musl's
# (`musl-gcc` on Ubuntu, for Linux on musl). Linux on glibc is built in the platform's container,
# manylinux_2_28 (AlmaLinux 8), so that the binary starts on glibc 2.28, as Node 24 itself does. Writes `<out>/engine-<platform>/reylo` (`reylo.exe` on
# Windows), which scripts/build.ts packages with `--binaries <out>`.
#
# .github/scripts/build-binary.sh <platform> <out>
#
# The build is the one scripts/build.ts makes on a developer's machine: the release profile,
# stripped of its symbol table (RELEASE_ENV there), with the SQLite flags of .cargo/config.toml.
set -euo pipefail

platform="${1:?usage: build-binary.sh <platform> <out>}"
out="${2:?usage: build-binary.sh <platform> <out>}"
target="$(node -p "(require('./npm/platforms.json').find((p) => p.platform === process.argv[1]) ?? {}).target ?? ''" "$platform")"
[ -n "$target" ] || { echo "::error::npm/platforms.json names no platform $platform"; exit 1; }

export CARGO_PROFILE_RELEASE_STRIP=symbols
case "$target" in
*-apple-darwin)
# The oldest macOS the binary starts on, for the C the crates compile as for rustc's own code.
export MACOSX_DEPLOYMENT_TARGET=11.0
;;
*-linux-gnu)
# The container's toolchain, and what the build reads beside it: libclang for bindgen, and
# python3, unzip and patch for the prepare step.
if command -v dnf > /dev/null; then
dnf install -y -q clang-devel python3 unzip patch > /dev/null
fi
;;
*-linux-musl)
# musl's compiler wrapper for the C (libpg_query, SQLite, ring) and the linker, and a static
# binary, as Rust's musl targets link by default: it runs on any Linux of its CPU.
sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends musl-tools > /dev/null
triple="${target//-/_}"
export "CC_$triple=musl-gcc" "AR_$triple=ar"
;;
*-pc-windows-msvc)
# The C runtime linked in, so that the binary starts on a Windows without the Visual C++
# redistributable, whose absence stops a program before it can say why (STATUS_DLL_NOT_FOUND).
export RUSTFLAGS="-C target-feature=+crt-static"
# bindgen reads libpg_query's header with the runner's LLVM.
export LIBCLANG_PATH="${LIBCLANG_PATH:-C:\\Program Files\\LLVM\\bin}"
;;
esac

# rustup, where the runner's image has none (the arm64 images are younger than the others).
if ! command -v rustup > /dev/null; then
case "$target" in
*-windows-*)
host=x86_64-pc-windows-msvc
[ "$(uname -m)" = aarch64 ] && host=aarch64-pc-windows-msvc
curl -fsSL -o "$RUNNER_TEMP/rustup-init.exe" "https://static.rust-lang.org/rustup/dist/$host/rustup-init.exe"
"$RUNNER_TEMP/rustup-init.exe" -y --profile minimal --default-toolchain stable
;;
*) curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable ;;
esac
export PATH="$HOME/.cargo/bin:$PATH"
fi
case "$target" in
*-pc-windows-msvc)
[ -f "$LIBCLANG_PATH/libclang.dll" ] || { echo "::error::no libclang.dll in $LIBCLANG_PATH, which bindgen reads libpg_query's header with: install LLVM, or name its bin directory in LIBCLANG_PATH"; exit 1; }
# The prepare step patches SQLite with python3, which Windows names python.
if ! command -v python3 > /dev/null; then
mkdir -p "$RUNNER_TEMP/python3"
printf '#!/bin/sh\nexec python "$@"\n' > "$RUNNER_TEMP/python3/python3"
chmod +x "$RUNNER_TEMP/python3/python3"
export PATH="$RUNNER_TEMP/python3:$PATH"
fi
;;
esac

rustup target add "$target"
engine/build.sh --prepare
cargo build --release --locked -p reylo --target "$target"

exe=reylo
case "$target" in *-windows-*) exe=reylo.exe ;; esac
mkdir -p "$out/engine-$platform"
cp "${CARGO_TARGET_DIR:-target}/$target/release/$exe" "$out/engine-$platform/$exe"
# The runner runs what it built, macOS on Intel under Rosetta where the runner has it: the binary
# must start, print reylo's version, and answer a ping as each database's engine.
bin="$out/engine-$platform/$exe"
run() { if [ "$platform" = darwin-x64 ]; then arch -x86_64 "$bin" "$@"; else "$bin" "$@"; fi; }
if [ "$platform" = darwin-x64 ] && ! arch -x86_64 /usr/bin/true 2> /dev/null; then
echo "::notice::$platform: this runner has no Rosetta, so the binary is not run here"
else
printed="$(run --version)"
version="$(node -p "require('./package.json').version")"
[ "$printed" = "reylo $version" ] || { echo "::error::$platform: the binary prints \"$printed\", and reylo is $version"; exit 1; }
for db in postgres mysql sqlite duckdb; do
answer="$(echo '{"op":"ping"}' | run engine "$db" call)"
case "$answer" in *'"status":"ok"'*"\"database\":\"$db\""*) ;; *) echo "::error::$platform: reylo engine $db answered a ping with $answer"; exit 1 ;; esac
done
echo "$platform: $printed, and each database's engine answers"
fi
# A glibc binary starts on the oldest glibc whose version names every symbol it takes.
case "$target" in
*-linux-gnu)
newest="$(objdump -T "$bin" | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1)"
case "$newest" in
GLIBC_2.[0-9] | GLIBC_2.1[0-9] | GLIBC_2.1[0-9].* | GLIBC_2.2[0-8] | GLIBC_2.2[0-8].* | GLIBC_2.[0-9].*) echo "$platform: needs $newest at most" ;;
*) echo "::error::$platform: the binary needs $newest, newer than glibc 2.28"; exit 1 ;;
esac
;;
esac
ls -l "$bin"
27 changes: 27 additions & 0 deletions .github/scripts/build-playground.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Builds the docs site's playground engine on an x86_64 Linux runner, as engine/build.sh builds it
# (Postgres's engine as one WebAssembly component, which jco transpiles), and packs it into
# <out>/reylo-playground-postgres.tar.gz: `engine.js` and its `engine.core*.wasm` modules, nothing
# else, which the site's build fetches from the release (site/scripts/playground-engine.ts).
# It needs rustup and the root's install (jco), and fetches wasi-sdk 34 by its SHA-256.
#
# .github/scripts/build-playground.sh <out>
set -euo pipefail

[ $# -eq 1 ] || { echo "usage: build-playground.sh <out>" >&2; exit 2; }
mkdir -p "$1"
out=$(cd "$1" && pwd)

WASI_SDK=wasi-sdk-34.0-x86_64-linux
WASI_SDK_SHA256=b761e3a0721dbae9c09a0059e5fdb2bf917d1b4a8a7b430fb3b5aafb0984b2c4
tmp=${RUNNER_TEMP:-$(mktemp -d)}

rustup target add wasm32-wasip2
curl -fsSL -o "$tmp/$WASI_SDK.tar.gz" "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-34/$WASI_SDK.tar.gz"
echo "$WASI_SDK_SHA256 $tmp/$WASI_SDK.tar.gz" | sha256sum -c -
mkdir -p engine/.wasi-sdk && tar -xzf "$tmp/$WASI_SDK.tar.gz" -C engine/.wasi-sdk
export WASI_SDK_PATH="$PWD/engine/.wasi-sdk/$WASI_SDK"

engine/build.sh
(cd target/playground/postgres && tar -czf "$out/reylo-playground-postgres.tar.gz" engine.js engine.core*.wasm)
tar -tzvf "$out/reylo-playground-postgres.tar.gz"
153 changes: 153 additions & 0 deletions .github/scripts/install-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Installs reylo as a project does, from the packages a run built, on the platform it runs on, and
// checks that the engine installed for that platform types and runs a query: the nine packages
// published to a registry of the check's own (Verdaccio, holding nothing else), `npm install reylo`
// in a fresh project, which must bring this platform's engine package alone, `reylo generate` on a
// SQLite query, a program run under the reylo/register preload, and Postgres statements nested to
// the engine's bound, analyzed by the installed binary as tests/depth.test.ts analyzes them.
//
// node .github/scripts/install-check.ts <packs> <platform>
//
// <packs> holds the packed packages (`npm pack` of each directory scripts/build.ts writes), and
// <platform> is the npm/platforms.json platform this machine is, whose package must be the one
// installed. Plain Node runs it, on every platform and inside the Linux images binaries.yml names.
import { spawn, spawnSync, type SpawnSyncOptions } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { pathToFileURL } from "node:url";

const [packsArg, platform] = process.argv.slice(2);
if (!packsArg || !platform) throw new Error("usage: node .github/scripts/install-check.ts <packs> <platform>");
const packs = resolve(packsArg);
const VERDACCIO = "6.11.1";
const PORT = 48731;
const registry = `http://127.0.0.1:${PORT}/`;
const windows = process.platform === "win32";

// The installed reylo finds its engine by itself, as a project's does.
delete process.env.REYLO_ENGINE_PATH;
const root = mkdtempSync(join(tmpdir(), "reylo-install-"));
const step = (what: string) => console.log(`\n== ${what}`);

/** A command run to its end with its output shown, which must exit 0; npm through a shell on Windows, where it is npm.cmd. */
function run(command: string, args: string[], options: SpawnSyncOptions = {}): string {
const r = spawnSync(command, args, { encoding: "utf8", shell: windows && command === "npm", ...options });
const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
if (r.status !== 0) throw new Error(`${command} ${args.join(" ")} exited with ${r.status ?? r.signal}${r.error ? `: ${r.error.message}` : ""}\n${out}`);
return String(r.stdout ?? "");
}

const registryDir = join(root, "registry");
mkdirSync(registryDir);
writeFileSync(join(registryDir, "config.yaml"), [
"storage: ./storage",
"auth:",
" htpasswd:",
" file: ./htpasswd",
" max_users: 1",
"packages:",
" '**':",
" access: $all",
" publish: $authenticated",
"log: { type: stdout, format: pretty, level: warn }",
"max_body_size: 100mb",
"",
].join("\n"));

step(`Verdaccio ${VERDACCIO}, a registry of this check's own`);
run("npm", ["install", "--prefix", registryDir, `verdaccio@${VERDACCIO}`, "--no-audit", "--no-fund", "--registry", "https://registry.npmjs.org/"]);
const server = spawn(process.execPath, [join(registryDir, "node_modules", "verdaccio", "bin", "verdaccio"), "--config", join(registryDir, "config.yaml"), "--listen", `127.0.0.1:${PORT}`], { cwd: registryDir, stdio: "inherit" });

try {
for (let tries = 0; ; tries++) {
try {
if ((await fetch(`${registry}-/ping`)).ok) break;
} catch {}
if (tries > 120) throw new Error("Verdaccio did not answer within a minute");
await new Promise((done) => setTimeout(done, 500));
}
// A user of the registry's own, through the endpoint `npm adduser` posts to, and its token.
const user = await fetch(`${registry}-/user/org.couchdb.user:check`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "check", password: "check-password", type: "user" }) });
const { token } = (await user.json()) as { token?: string };
if (!token) throw new Error(`Verdaccio made no user: ${user.status}`);
const npmrc = join(root, ".npmrc");
writeFileSync(npmrc, `registry=${registry}\n//127.0.0.1:${PORT}/:_authToken=${token}\n`);

step("The nine packages, published to it");
const tarballs = readdirSync(packs).filter((f) => f.endsWith(".tgz")).sort();
if (tarballs.length !== 9) throw new Error(`${packs} holds ${tarballs.length} packages, not reylo and its eight engines: ${tarballs.join(", ")}`);
// The engines before reylo, as the release stages them.
for (const tgz of [...tarballs.filter((f) => !f.startsWith("reylo-")), ...tarballs.filter((f) => f.startsWith("reylo-"))]) {
run("npm", ["publish", join(packs, tgz), "--userconfig", npmrc, "--provenance=false"]);
console.log(`published ${tgz}`);
}

step("npm install reylo, in a fresh project");
const version = tarballs.find((f) => f.startsWith("reylo-"))!.replace(/^reylo-/, "").replace(/\.tgz$/, "");
const project = join(root, "project");
mkdirSync(project);
writeFileSync(join(project, "package.json"), JSON.stringify({ name: "check", private: true, type: "module", dependencies: { reylo: version } }, null, 2));
run("npm", ["install", "--userconfig", npmrc, "--no-audit", "--no-fund"], { cwd: project });
const engines = readdirSync(join(project, "node_modules", "@reylosql"));
console.log(`installed: ${engines.join(", ")}`);
if (engines.join() !== `engine-${platform}`) throw new Error(`npm installed ${engines.join(", ") || "no engine"}, where this machine's is engine-${platform} alone`);
const binary = join(project, "node_modules", "@reylosql", `engine-${platform}`, "bin", windows ? "reylo.exe" : "reylo");
const printed = run(binary, ["--version"]).trim();
if (printed !== `reylo ${version}`) throw new Error(`the installed binary prints "${printed}", where reylo is ${version}`);
console.log(printed);

step("reylo generate, and the preload, on a SQLite database");
const db = new DatabaseSync(join(project, "app.db"));
db.exec("create table users (id integer primary key, name text not null); insert into users values (1, 'ada')");
db.close();
writeFileSync(join(project, "user.sql"), "select id, name from users where id = :id");
writeFileSync(join(project, "main.ts"), `import { DatabaseSync } from "node:sqlite";\nimport user from "./user.sql";\nconsole.log(JSON.stringify(await user.run(new DatabaseSync("app.db"), { id: 1 })));\n`);
const env = { ...process.env, DATABASE_URL: "file:./app.db" };
const generated = run(process.execPath, [join(project, "node_modules", "reylo", "src", "cli.js"), "generate"], { cwd: project, env });
console.log(generated.trim());
if (!/user\.sql\s+✓/.test(generated)) throw new Error("reylo generate did not type user.sql");
if (!existsSync(join(project, ".reylo"))) throw new Error("reylo generate wrote no .reylo/");
const ran = run(process.execPath, ["--import", "reylo/register", "main.ts"], { cwd: project, env }).trim();
console.log(ran);
if (ran !== `{"id":1,"name":"ada"}`) throw new Error(`the preload's run printed ${ran}`);

step("Postgres statements nested to the engine's bound");
const { engine } = (await import(pathToFileURL(join(project, "node_modules", "reylo", "src", "engine.js")).href)) as typeof import("../../src/engine.ts");
const postgres = await engine("postgres");
// A server that describes every statement as one int4 column and answers every catalog query
// with no rows, as tests/depth.test.ts's does: the engine parses the statement and walks it whole.
const io = {
async describe(sql: string) {
const params = new Set(sql.match(/\$\d+/g) ?? []).size;
return { params: Array.from({ length: params }, () => ({ oid: 23 })), columns: [{ name: "x", oid: 23, table: 0, attnum: 0 }] };
},
async query() {
return [];
},
};
const fold = (n: number, start: string, f: (s: string, i: number) => string) => Array.from({ length: n }, (_, i) => i).reduce(f, start);
const deepest: [string, string][] = [
["sum, 496 deep", `select ${Array(496).fill("id").join(" + ")} as x from users`],
["case, 493 deep", `select ${fold(493, "0", (s, i) => `case when id = ${i} then ${i} else ${s} end`)} as x from users`],
["json_build_object, 495 deep", `select ${fold(495, "id", (s) => `json_build_object('a', ${s})`)} as x from users`],
["subquery, 164 deep", `select (${fold(164, "select 1", (s) => `select (${s})`)}) as x from users`],
];
for (const [shape, sql] of deepest) {
const at = await postgres.analyze(sql, "postgres", io);
if (at.kind !== "select" || at.cardinalityReason === "the statement did not parse locally") throw new Error(`the engine did not read the ${shape} statement whole`);
console.log(`${shape}: read whole`);
}
const past = await postgres.analyze(`select (${fold(165, "select 1", (s) => `select (${s})`)}) as x from users`, "postgres", io);
if (past.cardinalityReason !== "the statement did not parse locally") throw new Error("a statement past the bound was not refused as too deep");
console.log("subquery, 165 deep: past the bound, typed from the describe");

console.log(`\n${platform}: installed, typed and ran`);
} finally {
server.kill();
// Windows keeps the files of a program that runs, and the engine's children run until this
// process ends: what is left of the directory goes with the runner's own temporary files.
try {
rmSync(root, { recursive: true, force: true, maxRetries: 5 });
} catch {}
}
4 changes: 2 additions & 2 deletions .github/scripts/npm-stage-publish.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Stage one built package directory on npm: `npm stage publish` through OIDC trusted publishing.
#
# CI can only STAGE. The trusted publisher on reylo and on each @reylosql engine is stage-only, so a
# CI can only STAGE. The trusted publisher on reylo and on each @reylosql/engine-<platform> is stage-only, so a
# version reaches the registry only once a maintainer approves it with 2FA — a proof of presence that
# no token, and no stolen push credential, can supply.
#
Expand All @@ -19,7 +19,7 @@ version="${VERSION:?VERSION must name the version being staged}"
name="$(nub --node -p "require(require('node:path').resolve('$dir', 'package.json')).name")"
built="$(nub --node -p "require(require('node:path').resolve('$dir', 'package.json')).version")"
[ "$built" = "$version" ] || { echo "::error::$dir holds $name@$built, not $version"; exit 1; }
# registry.npmjs.org wants the scope separator percent-encoded: @reylosql/postgres -> @reylosql%2fpostgres.
# registry.npmjs.org wants the scope separator percent-encoded: @reylosql/engine-linux-x64 -> @reylosql%2fengine-linux-x64.
encoded="${name/\//%2f}"

if curl -sf "https://registry.npmjs.org/$encoded/$version" > /dev/null; then
Expand Down
Loading
Loading
0