feat: compile the server into Bun single-file executables (#485)

Signed-off-by: Kevin Cui <[email protected]>
This commit is contained in:
Kevin Cui
2026-09-03 19:01:57 +08:00
committed by GitHub
parent 1b7458a018
commit a3708655b3
25 changed files with 1636 additions and 99 deletions
+1
View File
@@ -0,0 +1 @@
1.4.0
+247
View File
@@ -0,0 +1,247 @@
name: Build Binaries
# Builds the Bun single-file executables (`npm run build:binary`, six targets)
# and smoke-tests every one of them on real hardware:
#
# build (Linux x64) -> smoke linux-arm64 (Blacksmith Ubuntu ARM)
# -> smoke windows-x64 (Blacksmith Windows Server 2025)
# -> smoke windows-arm64 (GitHub-hosted windows-11-arm)
# -> smoke darwin-arm64 (Blacksmith macOS 15, Apple Silicon)
# -> smoke darwin-x64 (GitHub-hosted macos-15-intel)
#
# All six targets are cross-compiled on ONE Linux runner: Bun downloads a
# ~80 MB runtime per target and compiling is a matter of seconds, whereas
# cross-compiling ON a Windows runner is broken upstream (oven-sh/bun#11198,
# the workspace and the Bun cache sit on different drives). The Linux job also
# smoke-tests its own linux-x64 output twice, once against SQLite and once
# against the PostgreSQL service container (service containers are Linux-only,
# which is one reason the smoke matrix is a separate job).
#
# The smoke matrix mixes runner vendors because Blacksmith offers no Windows
# ARM64 and no macOS Intel runners; those two targets run on GitHub-hosted
# runners, which are free for public repositories. Every smoke job only
# downloads its artifact and runs `node scripts/smoke-binary.ts`, so it needs
# neither `npm ci` nor Bun.
#
# The build job's `npm run build:binary` runs `npm run build:web`, which fetches
# https://oomol.com/en/apps/catalog.json (the provider icon map) at build time,
# so an outage there fails the build job. This is the first PR-gated path
# through the web build.
#
# darwin binaries built on Linux carry an invalid ad-hoc signature (Bun 1.4.0
# writes a wrong page hash, which macOS 27 rejects), so the macOS smoke jobs
# re-sign the binary with `codesign` and verify the signature before running it.
#
# A `runs-on` label that no runner serves queues forever instead of failing, so
# `workflow_dispatch` is here to verify the labels before relying on them.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# Cancel superseded runs for the same ref (e.g. new push to an open PR).
concurrency:
group: build-binary-${{ github.ref }}
cancel-in-progress: true
# Least privilege: these jobs only read the repository; the binaries are
# workflow artifacts, nothing is published.
permissions:
contents: read
env:
# This project runs TypeScript directly with `node` (native type stripping) and
# forbids `tsx` / `--experimental-strip-types` (see AGENTS.md). Type stripping is
# on by default only on Node >= 22.18 / >= 23.6, so pin an LTS that supports it.
# Do NOT downgrade below 22.18 — `npm ci` (postinstall codegen) and typecheck fail.
NODE_VERSION: "24"
jobs:
build:
name: Build all targets
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_DB: open_connector_test
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- name: Checkout
uses: actions/checkout@v7
with:
# No step needs the persisted GITHUB_TOKEN in git config; dropping it
# keeps `npm ci` postinstall scripts from reading it (zizmor hardening).
persist-credentials: false
- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
# Pinned to an immutable SHA rather than the mutable v2 tag: this
# third-party action installs the toolchain that produces the shipped
# binaries, so its supply chain must not be silently updatable.
# `.bun-version` is the single owner of the pinned Bun version; the build
# script refuses to run under any other version.
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version
# Bun downloads one runtime per cross-compile target (~80 MB each) from
# registry.npmjs.org into ~/.bun/install/cache; setup-bun caches only the
# host executable. The key changes with the pinned Bun version.
- name: Cache Bun cross-compile runtimes
uses: actions/cache@v6
with:
path: ~/.bun/install/cache/bun-*-v*
key: bun-runtimes-${{ runner.os }}-${{ hashFiles('.bun-version') }}
# Runs postinstall codegen (provider registry + catalog).
- name: Install dependencies
run: npm ci
# Regenerates the catalog, builds the web console, then compiles all six targets.
- name: Build binaries
run: npm run build:binary
# SQLite mode: the embedded catalog, migrations and console all come from the binary.
- name: Smoke test linux-x64 (SQLite)
run: node scripts/smoke-binary.ts dist/open-connector-linux-x64
# PostgreSQL mode: the binary's own `migrate` subcommand applies the embedded
# postgresql/ migrations, then the server must pass its schema check.
- name: Migrate PostgreSQL with linux-x64
env:
OOMOL_CONNECT_DATABASE_URL: postgresql://postgres:[email protected]:5432/open_connector_test
run: dist/open-connector-linux-x64 migrate
- name: Smoke test linux-x64 (PostgreSQL)
env:
OOMOL_CONNECT_DATABASE_URL: postgresql://postgres:[email protected]:5432/open_connector_test
run: node scripts/smoke-binary.ts dist/open-connector-linux-x64
# One artifact per target so each smoke job downloads only its own ~35 MB
# zip. upload-artifact drops the exec bit; the smoke jobs chmod after download.
- name: Upload linux-x64
uses: actions/upload-artifact@v7
with:
name: binary-linux-x64
path: dist/open-connector-linux-x64
if-no-files-found: error
retention-days: 1
- name: Upload linux-arm64
uses: actions/upload-artifact@v7
with:
name: binary-linux-arm64
path: dist/open-connector-linux-arm64
if-no-files-found: error
retention-days: 1
- name: Upload darwin-x64
uses: actions/upload-artifact@v7
with:
name: binary-darwin-x64
path: dist/open-connector-darwin-x64
if-no-files-found: error
retention-days: 1
- name: Upload darwin-arm64
uses: actions/upload-artifact@v7
with:
name: binary-darwin-arm64
path: dist/open-connector-darwin-arm64
if-no-files-found: error
retention-days: 1
- name: Upload windows-x64
uses: actions/upload-artifact@v7
with:
name: binary-windows-x64
path: dist/open-connector-windows-x64.exe
if-no-files-found: error
retention-days: 1
- name: Upload windows-arm64
uses: actions/upload-artifact@v7
with:
name: binary-windows-arm64
path: dist/open-connector-windows-arm64.exe
if-no-files-found: error
retention-days: 1
# Run each remaining target on its native OS and architecture. linux-x64 was
# already exercised by the build job.
smoke:
name: Smoke ${{ matrix.target }}
needs: build
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- target: linux-arm64
runner: blacksmith-2vcpu-ubuntu-2404-arm
file: open-connector-linux-arm64
- target: windows-x64
runner: blacksmith-2vcpu-windows-2025
file: open-connector-windows-x64.exe
# Blacksmith has no Windows ARM64 runners.
- target: windows-arm64
runner: windows-11-arm
file: open-connector-windows-arm64.exe
- target: darwin-arm64
runner: blacksmith-6vcpu-macos-15
file: open-connector-darwin-arm64
# Blacksmith has no macOS Intel runners.
- target: darwin-x64
runner: macos-15-intel
file: open-connector-darwin-x64
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
# The smoke script uses only node: builtins, so no `npm ci` is needed.
- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
- name: Download ${{ matrix.target }} binary
uses: actions/download-artifact@v8
with:
name: binary-${{ matrix.target }}
path: dist
# Artifact zips do not preserve file modes.
- name: Restore executable bit
if: runner.os != 'Windows'
run: chmod +x dist/${{ matrix.file }}
# Linux-built darwin binaries carry an invalid ad-hoc signature (Bun 1.4.0
# writes a wrong page hash); re-sign them and assert the signature is valid,
# since macOS 15 would tolerate an invalid one silently.
- name: Ad-hoc sign and verify
if: runner.os == 'macOS'
run: |
codesign --force --sign - dist/${{ matrix.file }}
codesign --verify --verbose=2 dist/${{ matrix.file }}
- name: Smoke test ${{ matrix.target }}
run: node scripts/smoke-binary.ts dist/${{ matrix.file }}
+1
View File
@@ -275,6 +275,7 @@ Issues and pull requests are welcome.
- [Fly.io deployment](docs/fly-io.md)
- [Cloudflare deployment](docs/cloudflare.md)
- [Docker image (GHCR)](docs/docker-ghcr.md)
- [Single binary](docs/single-binary.md)
- [Configuration](docs/configuration.md)
- [Credentials and OAuth](docs/credentials.md)
- [Catalog format](docs/catalog-format.md)
+109
View File
@@ -0,0 +1,109 @@
# Single Binary
OpenConnector can be compiled into one self-contained executable per platform with
[Bun](https://bun.com/docs/bundler/executables). The binary embeds the runtime, the generated
provider catalog, the database migrations, and the built web console, so it runs from any directory
without a Node.js installation, a checkout, or `node_modules`. Nothing is extracted to disk at
runtime.
## Build
Building requires Node.js for the npm scripts and the Bun version pinned in `.bun-version`; the
build script exits with an error under any other Bun version. Compile on Linux or macOS.
Cross-compiling on a Windows host fails upstream
([oven-sh/bun#11198](https://github.com/oven-sh/bun/issues/11198)).
```bash
npm install
npm run build:binary
```
`npm run build:binary` regenerates the catalog, builds the web console, and then writes six files
under `dist/`:
```text
dist/open-connector-linux-x64
dist/open-connector-linux-arm64
dist/open-connector-darwin-x64
dist/open-connector-darwin-arm64
dist/open-connector-windows-x64.exe
dist/open-connector-windows-arm64.exe
```
Each file is roughly 145 to 170 MiB. To build a subset, pass one or more target names after `--`:
```bash
npm run build:binary -- linux-x64 darwin-arm64
```
Bun downloads a runtime for every target that differs from the host (about 80 MB each, from
`registry.npmjs.org`) into `~/.bun/install/cache` on first use. These downloads are not integrity
checked by Bun; TLS is the only protection.
`npm run build:web` also fetches the provider icon map from https://oomol.com/en/apps/catalog.json
and fails when it is unreachable.
`.bun-version` and the `@types/bun` devDependency pin in `package.json` are bumped together.
## Run
The binary takes the same environment variables as `npm start`; see
[configuration.md](configuration.md) for the full reference. The ones you will usually set:
| Variable | Default | Meaning |
| ---------------------------- | ----------- | ----------------------------------------------------------------- |
| `OOMOL_CONNECT_DATA_DIR` | `./data` | SQLite database, transit files, and upload staging. |
| `PORT` | `3000` | HTTP port. |
| `HOST` | `127.0.0.1` | Bind address. |
| `OOMOL_CONNECT_DATABASE_URL` | unset | PostgreSQL connection URL. When unset, SQLite under the data dir. |
```bash
OOMOL_CONNECT_DATA_DIR="$HOME/open-connector-data" \
PORT=3000 \
./dist/open-connector-linux-x64
```
With SQLite, migrations are applied automatically when the database opens, exactly as with
`npm start`.
### PostgreSQL Migrations
PostgreSQL migrations are explicit. The binary has a `migrate` subcommand that applies the embedded
migrations and exits without starting the server. Run it before the first start and before starting
a newer binary that contains pending migrations:
```bash
OOMOL_CONNECT_DATABASE_URL="postgresql://open_connector:[email protected]:5432/open_connector?sslmode=verify-full" \
./dist/open-connector-linux-x64 migrate
OOMOL_CONNECT_DATABASE_URL="postgresql://open_connector:[email protected]:5432/open_connector?sslmode=verify-full" \
./dist/open-connector-linux-x64
```
The server only checks schema readiness at startup and refuses to start when migrations are
missing; it never applies PostgreSQL DDL itself. Without `OOMOL_CONNECT_DATABASE_URL`, `migrate`
prints a notice that SQLite migrations are applied automatically and exits.
## Differences From `npm start`
- `NODE_ENV` is fixed to `production` inside the binary, so logs are always JSON (no pretty
printing). `OOMOL_CONNECT_LOG_LEVEL` and every other environment variable are read at runtime as
usual.
- macOS: binaries built on macOS are ad-hoc signed by the build script. Binaries built on another
operating system carry an invalid ad-hoc signature, and macOS 27 and newer refuses to run them
until you re-sign them:
```bash
codesign --force --sign - dist/open-connector-darwin-arm64
```
`codesign --verify --verbose=2 <file>` prints "valid on disk" for a usable binary and "invalid
signature" for one that still needs re-signing.
## Notes
- Like `npm start`, the binary does not load `.env` files (Bun's automatic loading is disabled at
build time).
- On Windows, stopping the process from a process manager or `taskkill` terminates it immediately;
the graceful shutdown hook that closes the HTTP server and the database on Linux and macOS does
not run. This matches `node src/server/index.ts` on Windows.
+21
View File
@@ -45,6 +45,7 @@
"@electric-sql/pglite-socket": "^0.2.8",
"@hyrious/configs": "^0.1.6",
"@types/ali-oss": "^6.23.3",
"@types/bun": "1.4.0",
"@types/busboy": "^1.5.4",
"@types/mailparser": "^3.4.6",
"@types/mdast": "^4.0.4",
@@ -4125,6 +4126,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/bun": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.4.0.tgz",
"integrity": "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bun-types": "1.4.0"
}
},
"node_modules/@types/busboy": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
@@ -5115,6 +5126,16 @@
"integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==",
"license": "MIT"
},
"node_modules/bun-types": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.4.0.tgz",
"integrity": "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+2
View File
@@ -11,6 +11,7 @@
"dev": "node scripts/dev-local.ts",
"build": "npm run typecheck",
"build:web": "npm run build --workspace web",
"build:binary": "npm run generate:catalog && npm run build:web && bun scripts/build-binary.ts",
"dev:cloudflare": "npm run generate:catalog && npm run build:web && node scripts/copy-catalog-assets.ts && wrangler dev --config wrangler.local.jsonc",
"deploy:cloudflare": "npm run generate:catalog && npm run build:web && node scripts/copy-catalog-assets.ts && wrangler deploy --config wrangler.local.jsonc --minify",
"start": "node scripts/ensure-generated.ts && node src/server/index.ts",
@@ -61,6 +62,7 @@
"@electric-sql/pglite-socket": "^0.2.8",
"@hyrious/configs": "^0.1.6",
"@types/ali-oss": "^6.23.3",
"@types/bun": "1.4.0",
"@types/busboy": "^1.5.4",
"@types/mailparser": "^3.4.6",
"@types/mdast": "^4.0.4",
+194
View File
@@ -0,0 +1,194 @@
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
// Build the single-file server executables with Bun.
//
// Usage: `bun scripts/build-binary.ts [target-name ...]` (normally through `npm run build:binary`, which
// regenerates the catalog and builds the web console first). No arguments builds every target.
interface BinaryTarget {
/** Suffix of the output file: `dist/open-connector-<name>` (Bun appends `.exe` for Windows targets). */
name: string;
target: Bun.Build.CompileTarget;
}
const rootDir = resolve(import.meta.dirname, "..");
const bunVersionFile = join(rootDir, ".bun-version");
const targets: BinaryTarget[] = [
{ name: "linux-x64", target: "bun-linux-x64" },
{ name: "linux-arm64", target: "bun-linux-arm64" },
{ name: "darwin-x64", target: "bun-darwin-x64" },
{ name: "darwin-arm64", target: "bun-darwin-arm64" },
{ name: "windows-x64", target: "bun-windows-x64" },
{ name: "windows-arm64", target: "bun-windows-arm64" },
];
assertPinnedBunVersion();
assertBuildInputs();
for (const target of selectTargets(process.argv.slice(2))) {
await buildTarget(target);
}
/** `.bun-version` is the single owner of the pinned Bun version; CI installs it and local builds must match it. */
function assertPinnedBunVersion(): void {
const pinnedVersion = readFileSync(bunVersionFile, "utf8").trim();
if (Bun.version !== pinnedVersion) {
fail(
`Bun ${Bun.version} is running but .bun-version pins ${pinnedVersion}. Install it with: curl -fsSL https://bun.sh/install | bash -s "bun-v${pinnedVersion}"`,
);
}
}
/** The embedded directories are generated; refuse to build a binary that would ship an empty catalog or console. */
function assertBuildInputs(): void {
const problems: string[] = [];
if (listFiles(join(rootDir, "catalog/apps"), ".json").length === 0) {
problems.push("catalog/apps contains no .json files");
}
if (listFiles(join(rootDir, "migrations"), ".sql").length === 0) {
problems.push("migrations contains no .sql files");
}
if (listFiles(join(rootDir, "migrations/postgresql"), ".sql").length === 0) {
problems.push("migrations/postgresql contains no .sql files");
}
if (!existsSync(join(rootDir, "dist/web/index.html"))) {
problems.push("dist/web/index.html is missing");
}
if (problems.length > 0) {
fail(
`Build inputs are missing:\n - ${problems.join("\n - ")}\nRun "npm run build:binary" so the catalog and the web console are generated before the binary is built.`,
);
}
}
function listFiles(directory: string, extension: string): string[] {
try {
return readdirSync(directory).filter((name) => name.endsWith(extension));
} catch {
return [];
}
}
function selectTargets(names: string[]): BinaryTarget[] {
if (names.length === 0) {
return targets;
}
return names.map((name) => {
const target = targets.find((candidate) => candidate.name === name);
if (!target) {
fail(`Unknown target "${name}". Valid targets: ${targets.map((candidate) => candidate.name).join(", ")}.`);
}
return target;
});
}
async function buildTarget(binaryTarget: BinaryTarget): Promise<void> {
const outfile = join(rootDir, "dist", `open-connector-${binaryTarget.name}`);
// Bun appends .exe to Windows outputs; clear both spellings so an earlier build can never be mistaken for this one.
rmSync(outfile, { force: true });
rmSync(`${outfile}.exe`, { force: true });
const result = await runBunBuild(binaryTarget.target, outfile);
const [artifact] = result.outputs;
if (!artifact) {
fail(`Bun.build produced no output for ${binaryTarget.name}.`);
}
if (binaryTarget.target.startsWith("bun-darwin-")) {
signDarwinBinary(artifact.path);
}
const sizeMiB = statSync(artifact.path).size / (1024 * 1024);
console.log(`built ${relative(rootDir, artifact.path)} (${sizeMiB.toFixed(1)} MiB)`);
}
async function runBunBuild(target: Bun.Build.CompileTarget, outfile: string): Promise<Bun.BuildOutput> {
let result: Bun.BuildOutput;
try {
result = await Bun.build({
entrypoints: [join(rootDir, "src/server/index.ts")],
target: "bun",
format: "esm",
// ali-oss depends on urllib, which lazily `require("proxy-agent")`, an optional peer dependency that is not
// installed here. The bundler cannot resolve it, so it stays a runtime require that is never reached.
external: ["proxy-agent"],
// Bun inlines `process.env.NODE_ENV` at compile time ("development" unless defined). src/server/logger.ts then
// loads the pino-pretty worker transport, which cannot run inside a standalone executable. Every other
// environment variable is still read at runtime.
define: { "process.env.NODE_ENV": JSON.stringify("production") },
compile: {
target,
outfile,
// Each directory is embedded under its basename next to the bundle: migrations/, apps/ and web/.
// catalog/apps rather than catalog/: an interrupted `npm run generate:catalog` leaves catalog/.apps-<pid>-<ts>
// temp directories behind, and they must never end up inside a release.
assets: [join(rootDir, "migrations"), join(rootDir, "catalog/apps"), join(rootDir, "dist/web")],
// `node src/server/index.ts` reads neither .env nor bunfig.toml; keep the binary's configuration surface the same.
autoloadDotenv: false,
autoloadBunfig: false,
},
});
} catch (error) {
// Bun.build rejects with an AggregateError whose `errors` hold the BuildMessage / ResolveMessage entries.
if (error instanceof AggregateError) {
for (const message of error.errors) {
console.error(describeBuildMessage(message));
}
fail(`Bundling ${target} failed.`);
}
throw error;
}
for (const message of result.logs) {
console.error(describeBuildMessage(message));
}
if (!result.success) {
fail(`Bundling ${target} failed.`);
}
return result;
}
function describeBuildMessage(message: unknown): string {
if (message instanceof BuildMessage || message instanceof ResolveMessage) {
const position = message.position;
const location = position ? ` (${position.file}:${position.line}:${position.column})` : "";
return `${message.level}: ${message.message}${location}`;
}
return String(message);
}
/**
* Bun 1.4.0 writes an ad-hoc signature whose last page hash is wrong (oven-sh/bun#39837, fixed upstream but
* unreleased). macOS 27 refuses to start such a binary (SIGKILL at exec), so re-sign it in place. codesign only
* exists on macOS; darwin outputs built elsewhere are re-signed on the macOS smoke runner instead.
*/
function signDarwinBinary(output: string): void {
const displayPath = relative(rootDir, output);
if (process.platform !== "darwin") {
console.warn(
`warning: ${displayPath} still carries Bun's invalid ad-hoc signature because codesign is only available on macOS; run "codesign --force --sign - ${displayPath}" on macOS 27 or later before executing it.`,
);
return;
}
const result = spawnSync("codesign", ["--force", "--sign", "-", output], { stdio: "inherit" });
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
fail(
`codesign failed for ${displayPath} (${result.signal ? `signal ${result.signal}` : `exit code ${result.status}`}).`,
);
}
}
function fail(message: string): never {
console.error(message);
process.exit(1);
}
+9 -14
View File
@@ -1,11 +1,13 @@
import { mkdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { Pool } from "pg";
import { logger } from "../src/server/logger.ts";
import { createSecretCodec } from "../src/server/secrets/secret-codec.ts";
import { assertPostgresDatabaseUrl, createNodeRuntimeDatabase } from "../src/server/storage/node-runtime-database.ts";
import { migratePostgresDatabase } from "../src/server/storage/postgres-migrations.ts";
import {
createNodeRuntimeDatabase,
migratePostgresRuntimeDatabase,
sqliteMigrationsNotice,
} from "../src/server/storage/node-runtime-database.ts";
const { positionals, values: options } = parseArgs({
args: process.argv.slice(2),
@@ -49,20 +51,13 @@ const dataDir = resolve(options["data-dir"] ?? process.env.OOMOL_CONNECT_DATA_DI
const databasePath = join(dataDir, "connect.sqlite");
if (command === "migrate") {
if (!databaseUrl) {
console.log("SQLite migrations are applied automatically when the local runtime database opens.");
console.log(sqliteMigrationsNotice);
} else {
assertPostgresDatabaseUrl(databaseUrl);
const pool = new Pool({
application_name: "open-connector-migrate",
await migratePostgresRuntimeDatabase({
connectionString: databaseUrl,
connectionTimeoutMillis: readPositiveIntegerEnv("OOMOL_CONNECT_DATABASE_CONNECT_TIMEOUT_MS", 10_000),
max: 1,
connectionTimeoutMs: readPositiveIntegerEnv("OOMOL_CONNECT_DATABASE_CONNECT_TIMEOUT_MS", 10_000),
logger,
});
try {
await migratePostgresDatabase({ pool, logger });
} finally {
await pool.end();
}
}
} else {
const secretCodec = createSecretCodec(process.env.OOMOL_CONNECT_ENCRYPTION_KEY);
+349
View File
@@ -0,0 +1,349 @@
import type { ChildProcess } from "node:child_process";
import { strict as assert } from "node:assert";
import { spawn } from "node:child_process";
import { access, mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
// Start a built single-file executable against a fresh data directory and check that its embedded catalog,
// web console, migrations and shutdown path work.
//
// Usage: `node scripts/smoke-binary.ts <path-to-binary>`. Set OOMOL_CONNECT_DATABASE_URL to run the PostgreSQL
// mode; every other OOMOL_CONNECT_* variable is removed from the server's environment. Uses only Node built-ins so
// the smoke runners need no `npm ci`.
interface ProcessExit {
code: number | null;
signal: NodeJS.Signals | null;
}
interface ServerProcessOptions {
binaryPath: string;
dataDir: string;
port: number;
databaseUrl: string | undefined;
}
const healthTimeoutMs = 60_000;
const healthPollIntervalMs = 500;
const requestTimeoutMs = 10_000;
const shutdownTimeoutMs = 10_000;
/** The server binary with its captured output and exit state. */
class ServerProcess {
private readonly child: ChildProcess;
private readonly stdoutChunks: Buffer[] = [];
private readonly stderrChunks: Buffer[] = [];
private readonly closed: Promise<void>;
private exitState: ProcessExit | undefined;
private spawnError: Error | undefined;
constructor(options: ServerProcessOptions) {
this.child = spawn(options.binaryPath, [], {
cwd: options.dataDir,
env: buildServerEnvironment(options),
stdio: ["ignore", "pipe", "pipe"],
});
this.child.stdout?.on("data", (chunk: Buffer) => this.stdoutChunks.push(chunk));
this.child.stderr?.on("data", (chunk: Buffer) => this.stderrChunks.push(chunk));
this.closed = new Promise((resolveClosed) => {
this.child.once("error", (error) => {
this.spawnError = error;
this.exitState ??= { code: null, signal: null };
resolveClosed();
});
this.child.once("close", (code, signal) => {
this.exitState = { code, signal };
resolveClosed();
});
});
}
get exit(): ProcessExit | undefined {
return this.exitState;
}
get stdout(): string {
return Buffer.concat(this.stdoutChunks).toString("utf8");
}
get stderr(): string {
return Buffer.concat(this.stderrChunks).toString("utf8");
}
describeExit(): string {
if (this.spawnError) {
return `spawn failed: ${this.spawnError.message}`;
}
if (!this.exitState) {
return "still running";
}
return this.exitState.signal ? `signal ${this.exitState.signal}` : `exit code ${this.exitState.code}`;
}
kill(signal: NodeJS.Signals): void {
this.child.kill(signal);
}
/** Resolve with the exit state, or undefined when the process is still running after the timeout. */
async waitForExit(timeoutMs: number): Promise<ProcessExit | undefined> {
await Promise.race([this.closed, sleep(timeoutMs, undefined, { ref: false })]);
return this.exitState;
}
/** Terminate a process that is still running; no-op after it exited. */
async forceStop(): Promise<void> {
if (this.exitState) {
return;
}
this.kill("SIGKILL");
await this.waitForExit(shutdownTimeoutMs);
}
}
const binaryPath = await resolveBinaryPath(process.argv[2]);
const databaseUrl = process.env.OOMOL_CONNECT_DATABASE_URL?.trim() || undefined;
const mode = databaseUrl ? "postgresql" : "sqlite";
const startedAt = performance.now();
// Probe the port before creating the data directory so a probe failure cannot leak the temp directory.
const port = await findFreePort();
const dataDir = await mkdtemp(join(tmpdir(), "open-connector-smoke-"));
const baseUrl = `http://127.0.0.1:${port}`;
const server = new ServerProcess({ binaryPath, dataDir, port, databaseUrl });
try {
await waitForHealth(server, baseUrl);
const readyAt = performance.now();
const indexHtml = await checkConsoleIndex(baseUrl);
await checkConsoleAssets(baseUrl, indexHtml);
await checkProviders(baseUrl);
await checkApps(baseUrl);
await checkDatabaseBackend(server, dataDir, databaseUrl);
await checkGracefulShutdown(server);
await removeDataDir(dataDir);
console.log(
`PASS ${binaryPath} mode=${mode} startup=${formatMs(readyAt - startedAt)} total=${formatMs(performance.now() - startedAt)}`,
);
} catch (error) {
console.error(`FAIL ${binaryPath}: ${error instanceof Error ? error.message : String(error)}`);
printServerLogs(server);
process.exitCode = 1;
} finally {
await server.forceStop();
await removeDataDir(dataDir).catch(() => undefined);
}
/** Resolve the binary before spawning: spawn resolves relative paths against the child's cwd, which is the data dir. */
async function resolveBinaryPath(argument: string | undefined): Promise<string> {
if (!argument) {
console.error("Usage: node scripts/smoke-binary.ts <path-to-binary>");
process.exit(1);
}
const resolved = resolve(argument);
try {
await access(resolved);
} catch {
console.error(`Binary not found: ${resolved}`);
process.exit(1);
}
return resolved;
}
/** process.env without OOMOL_CONNECT_* so the caller's shell cannot leak configuration into the server under test. */
function buildServerEnvironment(options: ServerProcessOptions): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(process.env)) {
if (!key.toUpperCase().startsWith("OOMOL_CONNECT_")) {
env[key] = value;
}
}
env.PORT = String(options.port);
env.HOST = "127.0.0.1";
env.OOMOL_CONNECT_DATA_DIR = options.dataDir;
if (options.databaseUrl) {
env.OOMOL_CONNECT_DATABASE_URL = options.databaseUrl;
}
return env;
}
function findFreePort(): Promise<number> {
return new Promise((resolvePort, reject) => {
const probe = createServer();
probe.once("error", reject);
probe.listen(0, "127.0.0.1", () => {
const address = probe.address();
if (!address || typeof address === "string") {
probe.close();
reject(new Error("Could not determine a free port."));
return;
}
const { port: freePort } = address;
probe.close((error) => (error ? reject(error) : resolvePort(freePort)));
});
});
}
async function waitForHealth(server: ServerProcess, baseUrl: string): Promise<void> {
const deadline = Date.now() + healthTimeoutMs;
while (Date.now() < deadline) {
if (server.exit) {
throw new Error(`server exited before /health responded (${server.describeExit()})`);
}
if (await isHealthy(baseUrl)) {
return;
}
await sleep(healthPollIntervalMs);
}
throw new Error(`/health did not respond within ${healthTimeoutMs} ms`);
}
async function isHealthy(baseUrl: string): Promise<boolean> {
try {
const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(healthPollIntervalMs * 4) });
await response.text();
return response.status === 200;
} catch {
return false;
}
}
async function checkConsoleIndex(baseUrl: string): Promise<string> {
const response = await fetch(`${baseUrl}/`, { signal: AbortSignal.timeout(requestTimeoutMs) });
const body = await response.text();
assert(response.status === 200, `GET / returned ${response.status}`);
const contentType = response.headers.get("content-type") ?? "";
assert(contentType.startsWith("text/html"), `GET / content-type is ${contentType || "missing"}`);
assert(body.includes("<script"), "GET / body contains no <script tag");
return body;
}
/**
* The SPA fallback answers any unknown console path with index.html, so GET / proves nothing about asset serving.
* Fetching the hashed files index.html references proves the embedded static map, including its path separators.
*/
async function checkConsoleAssets(baseUrl: string, indexHtml: string): Promise<void> {
const assetPaths = collectAssetPaths(indexHtml);
assert(assetPaths.length > 0, 'GET / references no src="/..." or href="/..." assets');
for (const assetPath of assetPaths) {
const response = await fetch(`${baseUrl}${assetPath}`, { signal: AbortSignal.timeout(requestTimeoutMs) });
const body = await response.arrayBuffer();
assert(response.status === 200, `GET ${assetPath} returned ${response.status}`);
const contentType = response.headers.get("content-type") ?? "";
assert(
!contentType.startsWith("text/html"),
`GET ${assetPath} was answered by the SPA fallback (content-type ${contentType || "missing"})`,
);
const contentLength = response.headers.get("content-length");
assert(
Number(contentLength) > 0 && Number(contentLength) === body.byteLength,
`GET ${assetPath} Content-Length ${contentLength ?? "missing"} does not match the ${body.byteLength}-byte body`,
);
}
}
/** Root-relative `src` and `href` values; protocol-relative `//host/...` URLs are not served by the binary. */
function collectAssetPaths(html: string): string[] {
const paths = new Set<string>();
for (const match of html.matchAll(/\b(?:src|href)="(\/[^"]*)"/g)) {
if (!match[1].startsWith("//")) {
paths.add(match[1]);
}
}
return [...paths];
}
async function checkProviders(baseUrl: string): Promise<void> {
const data = await fetchEnvelopeData(baseUrl, "/v1/providers");
assert(Array.isArray(data), "/v1/providers data is not an array");
assert(data.length > 1000, `/v1/providers returned ${data.length} providers; expected more than 1000`);
assert(
data.some((entry) => isRecord(entry) && entry.service === "slack"),
"/v1/providers does not include slack",
);
}
async function checkApps(baseUrl: string): Promise<void> {
const data = await fetchEnvelopeData(baseUrl, "/v1/apps");
assert(Array.isArray(data), "/v1/apps data is not an array");
}
/** Return `data` of a `{ success: true, data }` envelope. */
async function fetchEnvelopeData(baseUrl: string, path: string): Promise<unknown> {
const response = await fetch(`${baseUrl}${path}`, { signal: AbortSignal.timeout(requestTimeoutMs) });
const body = await response.text();
assert(response.status === 200, `GET ${path} returned ${response.status}`);
const payload: unknown = JSON.parse(body);
assert(isRecord(payload), `GET ${path} did not return a JSON object`);
assert(payload.success === true, `GET ${path} envelope success is ${JSON.stringify(payload.success)}`);
return payload.data;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function checkDatabaseBackend(
server: ServerProcess,
dataDir: string,
databaseUrl: string | undefined,
): Promise<void> {
if (databaseUrl) {
assert(server.stdout.includes('"backend":"postgresql"'), 'server log has no "backend":"postgresql" line');
return;
}
try {
await access(join(dataDir, "connect.sqlite"));
} catch {
throw new Error(`connect.sqlite was not created in ${dataDir}`);
}
}
async function checkGracefulShutdown(server: ServerProcess): Promise<void> {
server.kill("SIGTERM");
const exit = await server.waitForExit(shutdownTimeoutMs);
if (!exit) {
await server.forceStop();
throw new Error(`server did not exit within ${shutdownTimeoutMs} ms after SIGTERM`);
}
if (exit.code === 0) {
return;
}
// Node's kill() is TerminateProcess on Windows: the SIGTERM handler never runs, so graceful shutdown is not
// exercised there and the forced termination is the expected outcome.
if (process.platform === "win32" && exit.code === null && exit.signal === "SIGTERM") {
return;
}
throw new Error(`server exited with ${server.describeExit()} after SIGTERM; expected exit code 0`);
}
/** Windows can report EBUSY for a short while after the process exits; retry instead of failing. */
function removeDataDir(directory: string): Promise<void> {
return rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
}
function printServerLogs(server: ServerProcess): void {
console.error(`server ${server.describeExit()}`);
console.error("--- server stdout ---");
console.error(server.stdout);
console.error("--- server stderr ---");
console.error(server.stderr);
}
function formatMs(value: number): string {
return `${Math.round(value)}ms`;
}
+1 -4
View File
@@ -143,10 +143,7 @@ function toProviderSummary(provider: RuntimeProviderDefinition): ProviderSummary
/**
* Load generated provider catalog files from disk.
*/
export async function loadCatalog(
catalogDir: string = join(process.cwd(), "catalog/apps"),
options: LoadCatalogOptions = {},
): Promise<CatalogStore> {
export async function loadCatalog(catalogDir: string, options: LoadCatalogOptions = {}): Promise<CatalogStore> {
const entries = await readdir(catalogDir, { withFileTypes: true });
const providers = await Promise.all(
entries
+147
View File
@@ -0,0 +1,147 @@
import { Hono } from "hono";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { registerStaticRoutes } from "./static-routes.ts";
const indexHtml = '<!doctype html><div id="root"></div>';
const consoleScript = "console.log('ok');";
const headersFile = "/*\n X-Frame-Options: DENY\n";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
describe("registerStaticRoutes in embedded mode", () => {
it("serves index.html for the root path", async () => {
const app = await createEmbeddedApp();
const response = await app.request("/");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
expect(response.headers.get("content-length")).toBe(String(Buffer.byteLength(indexHtml)));
await expect(response.text()).resolves.toBe(indexHtml);
});
it("serves nested assets with their content type", async () => {
const app = await createEmbeddedApp();
const response = await app.request("/assets/x.js");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toMatch(/^text\/javascript/);
expect(response.headers.get("content-length")).toBe(String(Buffer.byteLength(consoleScript)));
await expect(response.text()).resolves.toBe(consoleScript);
});
it("falls back to application/octet-stream for files without an extension", async () => {
const app = await createEmbeddedApp();
const response = await app.request("/_headers");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/octet-stream");
await expect(response.text()).resolves.toBe(headersFile);
});
it("answers HEAD with headers only", async () => {
const app = await createEmbeddedApp();
const response = await app.request("/assets/x.js", { method: "HEAD" });
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toMatch(/^text\/javascript/);
expect(response.headers.get("content-length")).toBe(String(Buffer.byteLength(consoleScript)));
await expect(response.text()).resolves.toBe("");
});
it("serves the console shell for unknown console paths", async () => {
const app = await createEmbeddedApp();
const response = await app.request("/providers/github");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
await expect(response.text()).resolves.toBe(indexHtml);
});
it("keeps JSON 404 responses for API paths", async () => {
const app = await createEmbeddedApp();
const response = await app.request("/v1/nope");
expect(response.status).toBe(404);
expect(response.headers.get("content-type")).toContain("application/json");
await expect(response.json()).resolves.toEqual({
error: { code: "not_found", message: "Not found." },
});
});
it("does not serve assets for POST requests", async () => {
const app = await createEmbeddedApp();
const response = await app.request("/assets/x.js", { method: "POST" });
expect(response.status).toBe(404);
await expect(response.json()).resolves.toEqual({
error: { code: "not_found", message: "Not found." },
});
});
it("marks content-hashed assets immutable and leaves index.html uncached", async () => {
const app = await createEmbeddedApp();
const asset = await app.request("/assets/x.js");
const index = await app.request("/index.html");
expect(asset.status).toBe(200);
expect(asset.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
expect(index.status).toBe(200);
expect(index.headers.get("cache-control")).toBeNull();
});
it("loads the tree into memory at registration", async () => {
const root = await createConsoleRoot();
const app = new Hono();
registerStaticRoutes(app, { root, embedded: true });
await rm(root, { recursive: true, force: true });
const response = await app.request("/assets/x.js");
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(consoleScript);
});
});
describe("registerStaticRoutes in Node mode", () => {
it("streams assets from disk", async () => {
const root = await createConsoleRoot();
const app = new Hono();
registerStaticRoutes(app, { root });
const response = await app.request("/assets/x.js");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toMatch(/^text\/javascript/);
await expect(response.text()).resolves.toBe(consoleScript);
});
});
async function createEmbeddedApp(): Promise<Hono> {
const app = new Hono();
registerStaticRoutes(app, { root: await createConsoleRoot(), embedded: true });
return app;
}
async function createConsoleRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "oomol-connect-static-"));
tempDirs.push(root);
await mkdir(join(root, "assets"), { recursive: true });
await writeFile(join(root, "index.html"), indexHtml);
await writeFile(join(root, "assets", "x.js"), consoleScript);
await writeFile(join(root, "_headers"), headersFile);
return root;
}
+96 -9
View File
@@ -1,27 +1,57 @@
import type { Hono } from "hono";
import { serveStatic } from "@hono/node-server/serve-static";
import { getMimeType } from "hono/utils/mime";
import { readdirSync, readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { join, relative, sep } from "node:path";
import { isConsoleShellRequest } from "./console-paths.ts";
import { notFound } from "./http-utils.ts";
export interface StaticRoutesOptions {
/** Built console directory; undefined when the console is not built. */
root?: string;
/** Serve from an embedded (non-streamable, epoch-mtime) tree: files are loaded into memory once at registration. */
embedded?: boolean;
}
interface EmbeddedFile {
/** Backed by a plain ArrayBuffer, which is what Hono's response body type requires. */
body: Uint8Array<ArrayBuffer>;
contentType: string;
}
/** The console entry file is owned here: `/` resolves to `/index.html` in both serving modes. */
function rewriteRequestPath(path: string): string {
return path === "/" ? "/index.html" : path;
}
/**
* Register static web-console routes for the local server.
*
* The web console is intentionally outside `src` and may be absent during
* backend development. Hono's static middleware handles real files; this
* wrapper owns only the fallback behavior for API and browser requests.
*
* Inside a Bun standalone executable the console lives in the embedded tree,
* where `createReadStream` does not work and every mtime is the Unix epoch, so
* the streaming middleware cannot be used. Embedded mode instead reads the
* whole tree into memory once and answers from that map.
*/
export function registerStaticRoutes(app: Hono, root?: string): void {
export function registerStaticRoutes(app: Hono, options: StaticRoutesOptions = {}): void {
const root = options.root;
if (root) {
app.use(
"*",
serveStatic({
root,
rewriteRequestPath: (path) => (path === "/" ? "/index.html" : path),
}),
);
if (options.embedded) {
registerEmbeddedFiles(app, readEmbeddedFiles(root));
} else {
app.use(
"*",
serveStatic({
root,
rewriteRequestPath,
}),
);
}
}
app.notFound(async (context) => {
@@ -48,3 +78,60 @@ export function registerStaticRoutes(app: Hono, root?: string): void {
}
});
}
/**
* Walk the console tree synchronously: `createApp()` registers routes
* synchronously, so an asynchronous walk would race the first request.
*/
function readEmbeddedFiles(root: string): Map<string, EmbeddedFile> {
const files = new Map<string, EmbeddedFile>();
for (const entry of readdirSync(root, { withFileTypes: true, recursive: true })) {
if (!entry.isFile()) {
continue;
}
const absolute = join(entry.parentPath, entry.name);
// The recursive walk yields backslash-separated names on Windows; request
// paths always use forward slashes, so normalise the key or nothing matches.
const key = `/${relative(root, absolute).split(sep).join("/")}`;
files.set(key, {
body: readFileSync(absolute),
contentType: getMimeType(entry.name) ?? "application/octet-stream",
});
}
return files;
}
/**
* Serve the in-memory console files. No Last-Modified or ETag headers: embedded
* mtimes are the epoch, so unlike the streaming middleware there is nothing
* truthful to validate against. Content-hashed assets are marked immutable
* instead; everything else is served uncached.
*/
function registerEmbeddedFiles(app: Hono, files: Map<string, EmbeddedFile>): void {
app.use("*", async (context, next) => {
const method = context.req.method;
if (method !== "GET" && method !== "HEAD") {
return next();
}
const path = rewriteRequestPath(context.req.path);
const file = files.get(path);
if (!file) {
return next();
}
const headers: Record<string, string> = {
"Content-Type": file.contentType,
"Content-Length": String(file.body.byteLength),
};
// Vite names everything under assets/ by content hash, so a URL never changes
// meaning and a year-long immutable cache is safe; index.html and the other
// root files keep their names between releases and stay uncached. Embedded
// mode cannot offer Last-Modified as the alternative: every mtime in the
// embedded tree is the Unix epoch, so a validator would never see a change.
if (path.startsWith("/assets/")) {
headers["Cache-Control"] = "public, max-age=31536000, immutable";
}
return method === "HEAD" ? context.body(null, 200, headers) : context.body(file.body, 200, headers);
});
}
+1 -1
View File
@@ -3728,7 +3728,7 @@ function createTestServer(providers: ProviderDefinition[], options: CreateTestSe
uploadTransitFile: options.uploadTransitFile,
runtimeTokens,
runtimePolicyStore: options.runtimePolicyStore ?? new MemoryRuntimePolicyStore(),
registerStaticRoutes: staticRoot ? (app) => registerStaticRoutes(app, staticRoot) : undefined,
registerStaticRoutes: staticRoot ? (app) => registerStaticRoutes(app, { root: staticRoot }) : undefined,
auth: {
...options.auth,
hasRuntimeTokens: async () => (await runtimeTokens.listTokens()).length > 0,
+42 -17
View File
@@ -3,7 +3,7 @@ import type { ServerType } from "@hono/node-server";
import { S3Client } from "@aws-sdk/client-s3";
import { serve } from "@hono/node-server";
import { access, mkdir } from "node:fs/promises";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { loadCatalog } from "../catalog-store.ts";
import { ActionPolicyService, parseActionPolicyList } from "../core/action-policy.ts";
@@ -23,7 +23,12 @@ import { S3TransitFileService } from "./files/s3-transit-files.ts";
import { TransitFileService } from "./files/transit-files.ts";
import { logger } from "./logger.ts";
import { createSecretCodec } from "./secrets/secret-codec.ts";
import { createNodeRuntimeDatabase } from "./storage/node-runtime-database.ts";
import { resolveServerAssets } from "./server-assets.ts";
import {
createNodeRuntimeDatabase,
migratePostgresRuntimeDatabase,
sqliteMigrationsNotice,
} from "./storage/node-runtime-database.ts";
import { DEFAULT_RUN_LIMIT } from "./storage/runtime-store.ts";
const port = Number(process.env.PORT ?? 3000);
@@ -37,10 +42,22 @@ const databaseUrl = optionalEnv("OOMOL_CONNECT_DATABASE_URL");
const databasePoolMax = readPositiveIntegerEnv("OOMOL_CONNECT_DATABASE_POOL_MAX", 10);
const databaseConnectTimeoutMs = readPositiveIntegerEnv("OOMOL_CONNECT_DATABASE_CONNECT_TIMEOUT_MS", 10_000);
// The standalone binary embeds migrations/postgresql, but the PostgreSQL startup validator refuses to serve until
// they are applied and its error text points at `npm run runtime:migrate`, which a binary user does not have.
// `migrate` applies them from the same source the validator reads, so validation and execution cannot diverge.
const [command, ...rest] = process.argv.slice(2);
try {
await main();
if (command === undefined) {
await main();
} else if (command === "migrate" && rest.length === 0) {
await runMigrateCommand();
} else {
console.error("Usage: open-connector [migrate]");
process.exitCode = 1;
}
} catch (error) {
logger.error({ err: error }, "connect server failed");
logger.error({ err: error }, command === "migrate" ? "migrate failed" : "connect server failed");
process.exitCode = 1;
}
@@ -65,8 +82,8 @@ async function main(): Promise<void> {
const allowedCustomOAuth = parseActionPolicyList(process.env.OOMOL_CONNECT_ALLOWED_CUSTOM_OAUTH);
await mkdir(dataDir, { recursive: true });
const staticRoot = await resolveStaticRoot(join(process.cwd(), "dist/web"));
const catalog = await loadCatalog(undefined, {
const assets = await resolveServerAssets();
const catalog = await loadCatalog(assets.catalogDir, {
executableServices: Object.keys(executorModules),
});
const runtimeDatabase = databaseUrl
@@ -78,6 +95,7 @@ async function main(): Promise<void> {
runLimit,
poolMax: databasePoolMax,
connectionTimeoutMs: databaseConnectTimeoutMs,
migrations: assets.migrations,
})
: await createNodeRuntimeDatabase({
backend: "sqlite",
@@ -85,6 +103,7 @@ async function main(): Promise<void> {
logger,
secretCodec,
runLimit,
migrations: assets.migrations,
});
try {
@@ -106,7 +125,7 @@ async function main(): Promise<void> {
verifyRuntimeJwt,
actionPolicy,
allowedCustomOAuth,
registerStaticRoutes: (app) => registerStaticRoutes(app, staticRoot),
registerStaticRoutes: (app) => registerStaticRoutes(app, { root: assets.staticRoot, embedded: assets.embedded }),
logger,
});
@@ -133,7 +152,7 @@ async function main(): Promise<void> {
"runtime data encryption is disabled; set OOMOL_CONNECT_ENCRYPTION_KEY to encrypt stored credentials, Marketplace API keys, OAuth client configuration, pending OAuth state, and completed idempotent action responses",
);
}
if (!staticRoot) {
if (!assets.staticRoot) {
logger.warn("web console assets are not built; use http://localhost:5173 for local console development");
}
},
@@ -145,6 +164,21 @@ async function main(): Promise<void> {
}
}
async function runMigrateCommand(): Promise<void> {
if (!databaseUrl) {
logger.info(sqliteMigrationsNotice);
return;
}
const assets = await resolveServerAssets();
await migratePostgresRuntimeDatabase({
connectionString: databaseUrl,
connectionTimeoutMs: databaseConnectTimeoutMs,
logger,
migrations: assets.migrations,
});
}
function waitForShutdown(server: ServerType): Promise<void> {
return new Promise((resolve, reject) => {
let closing = false;
@@ -169,15 +203,6 @@ function waitForShutdown(server: ServerType): Promise<void> {
});
}
async function resolveStaticRoot(root: string): Promise<string | undefined> {
try {
await access(join(root, "index.html"));
return root;
} catch {
return undefined;
}
}
function readPositiveIntegerEnv(name: string, fallback: number): number {
const value = process.env[name];
if (value === undefined) {
+63
View File
@@ -0,0 +1,63 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveServerAssets } from "./server-assets.ts";
import { defaultMigrationSource } from "./storage/migration-source.ts";
const tempDirs: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
describe("resolveServerAssets", () => {
it("uses the working directory layout of npm start under Node", async () => {
const cwd = await createTempCwd();
await mkdir(join(cwd, "dist", "web"), { recursive: true });
await writeFile(join(cwd, "dist", "web", "index.html"), "<!doctype html>");
const assets = await resolveServerAssets();
expect(assets.catalogDir).toBe(join(cwd, "catalog/apps"));
expect(assets.migrations).toBe(defaultMigrationSource);
expect(assets.staticRoot).toBe(join(cwd, "dist/web"));
expect(assets.embedded).toBe(false);
});
it("reports the console as not built when dist/web/index.html is missing", async () => {
const cwd = await createTempCwd();
await mkdir(join(cwd, "dist", "web"), { recursive: true });
const assets = await resolveServerAssets();
expect(assets.catalogDir).toBe(join(cwd, "catalog/apps"));
expect(assets.migrations).toBe(defaultMigrationSource);
expect(assets.staticRoot).toBeUndefined();
expect(assets.embedded).toBe(false);
});
it("reads the tree embedded next to the module inside a Bun standalone executable", async () => {
vi.stubGlobal("Bun", { isStandaloneExecutable: true });
const root = import.meta.dirname;
const assets = await resolveServerAssets();
expect(assets.embedded).toBe(true);
expect(assets.catalogDir).toBe(join(root, "apps"));
// The directory source touches the filesystem lazily; the ENOENT it raises names the embedded directory.
expect(assets.migrations).not.toBe(defaultMigrationSource);
expect(() => assets.migrations.readMigrations("sqlite")).toThrow(join(root, "migrations"));
// No web/index.html lives beside this module, so the console is reported as not built.
expect(assets.staticRoot).toBeUndefined();
});
});
async function createTempCwd(): Promise<string> {
const cwd = await mkdtemp(join(tmpdir(), "oomol-connect-assets-"));
tempDirs.push(cwd);
vi.spyOn(process, "cwd").mockReturnValue(cwd);
return cwd;
}
+66
View File
@@ -0,0 +1,66 @@
import type { MigrationSource } from "./storage/migration-source.ts";
import { access } from "node:fs/promises";
import { join } from "node:path";
import { createDirectoryMigrationSource, defaultMigrationSource } from "./storage/migration-source.ts";
/**
* Locations of the assets that are generated or built outside `src` and read
* by the server at startup.
*/
export interface ServerAssets {
/** Directory of generated provider catalog JSON files. */
catalogDir: string;
migrations: MigrationSource;
/** Built web console directory, or undefined when the console is not built (index.html missing). */
staticRoot: string | undefined;
/**
* True when the directories above live in the read-only tree embedded in a
* Bun standalone executable. Embedded files cannot be streamed and report
* epoch mtimes.
*/
embedded: boolean;
}
/**
* Resolve where catalog, migrations and web console live for this process: the
* embedded tree in a standalone executable, otherwise the working directory and
* repository layout used by `npm start`.
*
* The build script embeds `migrations/`, `catalog/apps/` and `dist/web/` next
* to the bundled entry point, so inside the executable they appear under
* `import.meta.dirname` as `migrations/`, `apps/` and `web/`.
*/
export async function resolveServerAssets(): Promise<ServerAssets> {
if (isStandaloneExecutable()) {
const root = import.meta.dirname;
return {
catalogDir: join(root, "apps"),
migrations: createDirectoryMigrationSource(join(root, "migrations")),
staticRoot: await resolveStaticRoot(join(root, "web")),
embedded: true,
};
}
const cwd = process.cwd();
return {
catalogDir: join(cwd, "catalog/apps"),
migrations: defaultMigrationSource,
staticRoot: await resolveStaticRoot(join(cwd, "dist/web")),
embedded: false,
};
}
/** Only Bun defines the `Bun` global; Node and workerd never do, so the read is safe everywhere. */
function isStandaloneExecutable(): boolean {
return (globalThis as { Bun?: { isStandaloneExecutable?: boolean } }).Bun?.isStandaloneExecutable === true;
}
async function resolveStaticRoot(root: string): Promise<string | undefined> {
try {
await access(join(root, "index.html"));
return root;
} catch {
return undefined;
}
}
+3 -11
View File
@@ -1,15 +1,13 @@
import type { RuntimeActionHttpResult } from "../api/runtime-api.ts";
import type { D1DatabaseBinding, D1PreparedStatementBinding } from "../cloudflare/cloudflare-bindings.ts";
import { readFileSync, readdirSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
import { describe, expect, it } from "vitest";
import { AesGcmSecretCodec } from "../secrets/secret-codec.ts";
import { D1RuntimeDatabase } from "./d1-runtime-store.ts";
import { defaultMigrationSource } from "./migration-source.ts";
import { RuntimeTokenService } from "./runtime-token-service.ts";
const migrationDirectory = new URL("../../../migrations/", import.meta.url);
const githubProfile = {
accountId: "github:octocat",
displayName: "octocat",
@@ -607,14 +605,8 @@ class SqliteD1Database implements D1DatabaseBinding {
private readonly database = new DatabaseSync(":memory:");
constructor() {
// Derive the migration list the same way runSqliteMigrations does, so a new migration reaches the
// D1 harness on its own. The three lines stay duplicated here because sharing an owner would mean
// exporting from production code, which this test-only fix deliberately leaves alone.
const migrationFiles = readdirSync(migrationDirectory)
.filter((name) => /^\d+_.*\.sql$/.test(name))
.sort();
for (const file of migrationFiles) {
this.database.exec(readFileSync(new URL(file, migrationDirectory), "utf8"));
for (const migration of defaultMigrationSource.readMigrations("sqlite")) {
this.database.exec(migration.sql);
}
}
@@ -0,0 +1,98 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createDirectoryMigrationSource, defaultMigrationSource } from "./migration-source.ts";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
describe("createDirectoryMigrationSource", () => {
it("reads sqlite migrations from the directory and postgresql migrations from its subdirectory", async () => {
const directory = await createMigrationDirectory();
await writeFile(join(directory, "0001_sqlite.sql"), "create table sqlite_only (id integer);");
await writeFile(join(directory, "postgresql", "0001_postgres.sql"), "create table postgres_only (id integer);");
const source = createDirectoryMigrationSource(directory);
expect(source.readMigrations("sqlite")).toEqual([
{ name: "0001_sqlite.sql", sql: "create table sqlite_only (id integer);" },
]);
expect(source.readMigrations("postgresql")).toEqual([
{ name: "0001_postgres.sql", sql: "create table postgres_only (id integer);" },
]);
});
it("keeps only numbered .sql files and ignores other entries and directories", async () => {
const directory = await createMigrationDirectory();
await writeFile(join(directory, "0001_runtime.sql"), "select 1;");
await writeFile(join(directory, "README.md"), "# migrations");
await writeFile(join(directory, "notes.sql"), "select 2;");
await writeFile(join(directory, "0002_backup.sql.bak"), "select 3;");
await mkdir(join(directory, "archive"));
await mkdir(join(directory, "0002_archive.sql"));
expect(createDirectoryMigrationSource(directory).readMigrations("sqlite")).toEqual([
{ name: "0001_runtime.sql", sql: "select 1;" },
]);
});
it("sorts migrations by file name using string order", async () => {
const directory = await createMigrationDirectory();
await writeFile(join(directory, "0010_second.sql"), "select 10;");
await writeFile(join(directory, "0002_first.sql"), "select 2;");
await writeFile(join(directory, "10_last.sql"), "select 3;");
expect(
createDirectoryMigrationSource(directory)
.readMigrations("sqlite")
.map((migration) => migration.name),
).toEqual(["0002_first.sql", "0010_second.sql", "10_last.sql"]);
});
it("reads migration bodies as utf8", async () => {
const directory = await createMigrationDirectory();
const sql = "-- 运行时表\ncreate table runtime (name text);\n";
await writeFile(join(directory, "0001_runtime.sql"), sql, "utf8");
expect(createDirectoryMigrationSource(directory).readMigrations("sqlite")).toEqual([
{ name: "0001_runtime.sql", sql },
]);
});
it("defers filesystem errors to readMigrations", async () => {
const directory = await createMigrationDirectory();
await writeFile(join(directory, "0001_runtime.sql"), "select 1;");
await rm(join(directory, "postgresql"), { recursive: true });
const missing = createDirectoryMigrationSource(join(directory, "missing"));
expect(() => missing.readMigrations("sqlite")).toThrow(/ENOENT/);
expect(() => missing.readMigrations("postgresql")).toThrow(/ENOENT/);
const withoutPostgres = createDirectoryMigrationSource(directory);
expect(withoutPostgres.readMigrations("sqlite")).toEqual([{ name: "0001_runtime.sql", sql: "select 1;" }]);
expect(() => withoutPostgres.readMigrations("postgresql")).toThrow(/ENOENT/);
});
});
describe("defaultMigrationSource", () => {
it("resolves the repository migrations directory", () => {
const sqlite = defaultMigrationSource.readMigrations("sqlite");
expect(sqlite[0]).toMatchObject({ name: "0001_runtime.sql" });
expect(sqlite[0]?.sql).toContain("create table");
expect(sqlite.every((migration) => migration.name.endsWith(".sql"))).toBe(true);
const postgresql = defaultMigrationSource.readMigrations("postgresql");
expect(postgresql[0]).toMatchObject({ name: "0010_runtime.sql" });
expect(postgresql.map((migration) => migration.name)).not.toContain("0001_runtime.sql");
});
});
async function createMigrationDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "oomol-connect-migrations-"));
tempDirs.push(directory);
await mkdir(join(directory, "postgresql"));
return directory;
}
+36
View File
@@ -0,0 +1,36 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
/**
* Where runtime migrations come from. Validation and execution must read the same source so that
* a migration cannot be required by one and invisible to the other.
*/
export interface MigrationSource {
/** Migrations for one dialect, sorted by file name. Synchronous because SqliteRuntimeDatabase migrates inside its constructor. */
readMigrations(dialect: "sqlite" | "postgresql"): { name: string; sql: string }[];
}
/**
* Directory layout: <directory>/*.sql = sqlite, <directory>/postgresql/*.sql = postgresql.
* Construction performs no filesystem access; errors (ENOENT etc.) surface from readMigrations().
* Accepts a directory path string only (no URL): a file: URL base without a trailing slash silently resolves against the
* parent directory; not accepting URLs removes the trap by construction.
*/
export function createDirectoryMigrationSource(directory: string): MigrationSource {
return {
readMigrations(dialect) {
const dir = dialect === "sqlite" ? directory : join(directory, "postgresql");
return readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && /^\d+_.*\.sql$/.test(entry.name))
.map((entry) => entry.name)
.sort()
.map((name) => ({ name, sql: readFileSync(join(dir, name), "utf8") }));
},
};
}
/** The repository's migrations/ directory resolved relative to this module. Used by every Node (non-binary) entry point. */
export const defaultMigrationSource: MigrationSource = createDirectoryMigrationSource(
fileURLToPath(new URL("../../../migrations/", import.meta.url)),
);
+32 -1
View File
@@ -1,7 +1,10 @@
import type { RuntimeLogger } from "../../core/types.ts";
import type { ISecretCodec } from "../secrets/secret-codec-core.ts";
import type { MigrationSource } from "./migration-source.ts";
import type { RuntimeDatabase } from "./runtime-database.ts";
import { Pool } from "pg";
import { migratePostgresDatabase } from "./postgres-migrations.ts";
import { PostgresRuntimeDatabase } from "./postgres-runtime-store.ts";
import { SqliteRuntimeDatabase } from "./sqlite-runtime-store.ts";
@@ -15,6 +18,7 @@ interface CommonOptions {
logger?: RuntimeLogger;
runLimit?: number;
secretCodec?: ISecretCodec;
migrations?: MigrationSource;
}
interface SqliteOptions extends CommonOptions {
@@ -31,6 +35,13 @@ interface PostgresOptions extends CommonOptions {
export type NodeRuntimeDatabaseOptions = SqliteOptions | PostgresOptions;
export interface MigratePostgresRuntimeDatabaseOptions {
connectionString: string;
connectionTimeoutMs: number;
logger?: RuntimeLogger;
migrations?: MigrationSource;
}
export async function createNodeRuntimeDatabase(options: NodeRuntimeDatabaseOptions): Promise<NodeRuntimeDatabase> {
if (options.backend === "sqlite") {
return new SqliteRuntimeDatabase(options.path, options);
@@ -41,7 +52,27 @@ export async function createNodeRuntimeDatabase(options: NodeRuntimeDatabaseOpti
return await PostgresRuntimeDatabase.open(connectionString, options);
}
export function assertPostgresDatabaseUrl(value: string): void {
/** What `migrate` entry points print without OOMOL_CONNECT_DATABASE_URL: SQLite has no explicit migrate step. */
export const sqliteMigrationsNotice =
"SQLite migrations are applied automatically when the local runtime database opens.";
/** Validate the URL, open a single-connection pool named open-connector-migrate, apply pending migrations, and close the pool. */
export async function migratePostgresRuntimeDatabase(options: MigratePostgresRuntimeDatabaseOptions): Promise<void> {
assertPostgresDatabaseUrl(options.connectionString);
const pool = new Pool({
application_name: "open-connector-migrate",
connectionString: options.connectionString,
connectionTimeoutMillis: options.connectionTimeoutMs,
max: 1,
});
try {
await migratePostgresDatabase({ pool, logger: options.logger, migrations: options.migrations });
} finally {
await pool.end();
}
}
function assertPostgresDatabaseUrl(value: string): void {
let protocol: string;
try {
protocol = new URL(value).protocol;
+10 -21
View File
@@ -1,20 +1,16 @@
import type { RuntimeLogger } from "../../core/types.ts";
import type { MigrationSource } from "./migration-source.ts";
import type { Pool, PoolClient } from "pg";
import { readFileSync, readdirSync } from "node:fs";
import { defaultMigrationSource } from "./migration-source.ts";
const migrationDirectory = new URL("../../../migrations/postgresql/", import.meta.url);
const migrationLockNamespace = 1_326_382_671;
const migrationLockId = 1;
interface PostgresMigration {
name: string;
sql: string;
}
export interface PostgresMigrationOptions {
pool: Pool;
logger?: RuntimeLogger;
migrations?: MigrationSource;
}
export async function migratePostgresDatabase(options: PostgresMigrationOptions): Promise<void> {
@@ -31,7 +27,7 @@ export async function migratePostgresDatabase(options: PostgresMigrationOptions)
`);
const startedAt = Date.now();
const migrations = readPostgresMigrations();
const migrations = (options.migrations ?? defaultMigrationSource).readMigrations("postgresql");
const applied = await readAppliedMigrations(client);
let newlyAppliedCount = 0;
@@ -86,8 +82,11 @@ export async function migratePostgresDatabase(options: PostgresMigrationOptions)
}
}
export async function assertPostgresSchemaReady(pool: Pool): Promise<void> {
const migrations = readPostgresMigrations();
export async function assertPostgresSchemaReady(
pool: Pool,
migrations: MigrationSource = defaultMigrationSource,
): Promise<void> {
const required = migrations.readMigrations("postgresql");
const relation = await pool.query<{ name: string | null }>("select to_regclass($1) as name", ["runtime_migrations"]);
if (!relation.rows[0]?.name) {
throw new Error(
@@ -96,7 +95,7 @@ export async function assertPostgresSchemaReady(pool: Pool): Promise<void> {
}
const applied = await readAppliedMigrations(pool);
const missing = migrations.filter((migration) => !applied.has(migration.name)).map((migration) => migration.name);
const missing = required.filter((migration) => !applied.has(migration.name)).map((migration) => migration.name);
if (missing.length > 0) {
throw new Error(
`PostgreSQL runtime schema is not ready. Missing migrations: ${missing.join(", ")}. Run \`npm run runtime:migrate\` before starting the server.`,
@@ -104,16 +103,6 @@ export async function assertPostgresSchemaReady(pool: Pool): Promise<void> {
}
}
function readPostgresMigrations(): PostgresMigration[] {
return readdirSync(migrationDirectory)
.filter((name) => /^\d+_.*\.sql$/.test(name))
.sort()
.map((name) => ({
name,
sql: readFileSync(new URL(name, migrationDirectory), "utf8"),
}));
}
async function readAppliedMigrations(queryable: Pool | PoolClient): Promise<Set<string>> {
const result = await queryable.query<{ name: string }>("select name from runtime_migrations");
return new Set(result.rows.map((row) => row.name));
@@ -1,10 +1,12 @@
import type { RuntimeActionHttpResult } from "../api/runtime-api.ts";
import type { MigrationSource } from "./migration-source.ts";
import { PGlite } from "@electric-sql/pglite";
import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
import { Pool } from "pg";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { AesGcmSecretCodec } from "../secrets/secret-codec.ts";
import { defaultMigrationSource } from "./migration-source.ts";
import { assertPostgresSchemaReady, migratePostgresDatabase } from "./postgres-migrations.ts";
import { PostgresRuntimeDatabase } from "./postgres-runtime-store.ts";
import { RuntimeTokenService } from "./runtime-token-service.ts";
@@ -68,6 +70,62 @@ describe("PostgreSQL migrations with PGlite", () => {
});
});
describe("PostgreSQL migrations with a custom migration source", () => {
let testServer: PGliteTestServer;
beforeAll(async () => {
testServer = await startPGliteTestServer();
});
afterAll(async () => {
await testServer.server.stop();
await testServer.database.close();
});
it("validates and executes migrations through the same source", async () => {
const migrations: MigrationSource = {
readMigrations(dialect) {
return [
...defaultMigrationSource.readMigrations(dialect),
{ name: "9998_custom.sql", sql: "create table custom_records (id integer primary key)" },
];
},
};
const unapplied: MigrationSource = {
readMigrations: () => [{ name: "9999_unapplied.sql", sql: "select 1" }],
};
const pool = new Pool({ connectionString: testServer.url, max: 1 });
try {
await migratePostgresDatabase({ pool, migrations });
await expect(assertPostgresSchemaReady(pool, migrations)).resolves.toBeUndefined();
await expect(assertPostgresSchemaReady(pool)).resolves.toBeUndefined();
await expect(assertPostgresSchemaReady(pool, unapplied)).rejects.toThrow(
"Missing migrations: 9999_unapplied.sql",
);
await expect(pool.query("select name from runtime_migrations order by name")).resolves.toMatchObject({
rows: [
{ name: "0010_runtime.sql" },
{ name: "0011_runtime_token_connection_scope.sql" },
{ name: "0012_marketplace.sql" },
{ name: "9998_custom.sql" },
],
});
await expect(pool.query("select to_regclass($1) as name", ["custom_records"])).resolves.toMatchObject({
rows: [{ name: "custom_records" }],
});
} finally {
await pool.end();
}
await expect(PostgresRuntimeDatabase.open(testServer.url, { migrations: unapplied })).rejects.toThrow(
"Missing migrations: 9999_unapplied.sql",
);
const database = await PostgresRuntimeDatabase.open(testServer.url, { migrations });
await database.close();
});
});
describe("PostgresRuntimeDatabase with PGlite", () => {
let testServer: PGliteTestServer;
let database: PostgresRuntimeDatabase;
+3 -1
View File
@@ -15,6 +15,7 @@ import type {
IdempotencyClaimResult,
IIdempotencyStore,
} from "./idempotency-store.ts";
import type { MigrationSource } from "./migration-source.ts";
import type { RuntimeDatabase } from "./runtime-database.ts";
import type { IRuntimePolicyStore, RuntimePolicyRecord } from "./runtime-policy-store.ts";
import type { RuntimeRow } from "./runtime-sql.ts";
@@ -43,6 +44,7 @@ export interface PostgresRuntimeDatabaseOptions {
secretCodec?: ISecretCodec;
poolMax?: number;
connectionTimeoutMs?: number;
migrations?: MigrationSource;
}
export class PostgresRuntimeDatabase implements RuntimeDatabase {
@@ -86,7 +88,7 @@ export class PostgresRuntimeDatabase implements RuntimeDatabase {
});
try {
await assertPostgresSchemaReady(pool);
await assertPostgresSchemaReady(pool, options.migrations);
return new PostgresRuntimeDatabase(pool, options);
} catch (error) {
await pool.end();
@@ -1,12 +1,13 @@
import type { RuntimeActionHttpResult } from "../api/runtime-api.ts";
import { readFileSync } from "node:fs";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
import { AesGcmSecretCodec } from "../secrets/secret-codec.ts";
import { createDirectoryMigrationSource } from "./migration-source.ts";
import { RuntimeTokenService } from "./runtime-token-service.ts";
import { SqliteRunLogStore, SqliteRuntimeDatabase } from "./sqlite-runtime-store.ts";
@@ -459,6 +460,31 @@ describe("SqliteRuntimeDatabase", () => {
database.close();
});
it("applies migrations from a custom migration source", async () => {
const databasePath = await createDatabasePath();
const migrationDirectory = join(dirname(databasePath), "migrations");
await mkdir(migrationDirectory);
await writeFile(
join(migrationDirectory, "0001_custom.sql"),
"create table custom_records (id integer primary key);",
);
const database = new SqliteRuntimeDatabase(databasePath, {
migrations: createDirectoryMigrationSource(migrationDirectory),
});
database.close();
const inspected = new DatabaseSync(databasePath);
expect(inspected.prepare("select name from runtime_migrations order by name").all()).toEqual([
{ name: "0001_custom.sql" },
]);
expect(
inspected.prepare("select name from sqlite_master where type = 'table' and name = 'custom_records'").get(),
).toEqual({ name: "custom_records" });
expect(inspected.prepare("select name from sqlite_master where name = 'connections'").get()).toBeUndefined();
inspected.close();
});
it("keeps an inserted run when retention cleanup fails", async () => {
const raw = new DatabaseSync(":memory:");
for (const migration of [
+20 -19
View File
@@ -15,15 +15,16 @@ import type {
IdempotencyClaimResult,
IIdempotencyStore,
} from "./idempotency-store.ts";
import type { MigrationSource } from "./migration-source.ts";
import type { RuntimeDatabase } from "./runtime-database.ts";
import type { IRuntimePolicyStore, RuntimePolicyRecord } from "./runtime-policy-store.ts";
import type { IRunLogStore, RunLog, RunLogListInput, RunLogPage, RunLogWriteResult } from "./runtime-store.ts";
import type { IRuntimeTokenStore, RuntimeTokenRecord } from "./runtime-token-service.ts";
import { readFileSync, readdirSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
import { parseRuntimeActionHttpResult } from "../api/runtime-api.ts";
import { PlainTextSecretCodec } from "../secrets/secret-codec-core.ts";
import { defaultMigrationSource } from "./migration-source.ts";
import {
listRunLogs,
parseJson,
@@ -36,12 +37,12 @@ import {
import { DEFAULT_RUN_LIMIT } from "./runtime-store.ts";
type SecretJsonTable = "oauth_client_configs";
const migrationDirectory = new URL("../../../migrations/", import.meta.url);
export interface SqliteRuntimeDatabaseOptions {
logger?: RuntimeLogger;
runLimit?: number;
secretCodec?: ISecretCodec;
migrations?: MigrationSource;
}
interface SecretJsonInput {
@@ -95,7 +96,7 @@ export class SqliteRuntimeDatabase implements RuntimeDatabase {
constructor(filename: string, options: SqliteRuntimeDatabaseOptions = {}) {
this.database = new DatabaseSync(filename);
this.secretCodec = options.secretCodec ?? new PlainTextSecretCodec();
this.initialize(options.logger);
this.initialize(options.migrations ?? defaultMigrationSource, options.logger);
this.connectionStore = new SqliteConnectionStore(this.database, this.secretCodec);
this.oauthClientConfigStore = new SqliteOAuthClientConfigStore(this.database, this.secretCodec);
this.oauthStateStore = new SqliteOAuthStateStore(this.database, this.secretCodec);
@@ -156,9 +157,9 @@ export class SqliteRuntimeDatabase implements RuntimeDatabase {
`);
}
private initialize(logger?: RuntimeLogger): void {
private initialize(migrations: MigrationSource, logger?: RuntimeLogger): void {
this.database.exec("pragma journal_mode = wal;");
runSqliteMigrations(this.database, logger);
runSqliteMigrations(this.database, migrations, logger);
}
}
@@ -648,7 +649,7 @@ function insertRun(database: DatabaseSync, run: RunLog): void {
);
}
function runSqliteMigrations(database: DatabaseSync, logger?: RuntimeLogger): void {
function runSqliteMigrations(database: DatabaseSync, migrations: MigrationSource, logger?: RuntimeLogger): void {
const startedAt = Date.now();
database.exec(`
create table if not exists runtime_migrations (
@@ -662,42 +663,42 @@ function runSqliteMigrations(database: DatabaseSync, logger?: RuntimeLogger): vo
.all()
.map((row) => readString(row, "name")),
);
const migrationFiles = readdirSync(migrationDirectory)
.filter((name) => /^\d+_.*\.sql$/.test(name))
.sort();
const migrationFiles = migrations.readMigrations("sqlite");
let newlyAppliedCount = 0;
for (const file of migrationFiles) {
if (applied.has(file)) {
for (const migration of migrationFiles) {
if (applied.has(migration.name)) {
continue;
}
const migrationStartedAt = Date.now();
logger?.info({ migration: file }, "sqlite migration started");
logger?.info({ migration: migration.name }, "sqlite migration started");
try {
const sql = readFileSync(new URL(file, migrationDirectory), "utf8");
runInTransaction(database, () => {
database.exec(sql);
database.exec(migration.sql);
database
.prepare("insert into runtime_migrations (name, applied_at) values (?, ?)")
.run(file, new Date().toISOString());
.run(migration.name, new Date().toISOString());
});
} catch (error) {
logger?.error(
{ migration: file, durationMs: Date.now() - migrationStartedAt, err: error },
{ migration: migration.name, durationMs: Date.now() - migrationStartedAt, err: error },
"sqlite migration failed",
);
throw error;
}
applied.add(file);
applied.add(migration.name);
newlyAppliedCount += 1;
logger?.info({ migration: file, durationMs: Date.now() - migrationStartedAt }, "sqlite migration completed");
logger?.info(
{ migration: migration.name, durationMs: Date.now() - migrationStartedAt },
"sqlite migration completed",
);
}
logger?.info(
{
migrationCount: migrationFiles.length,
appliedCount: migrationFiles.filter((file) => applied.has(file)).length,
appliedCount: migrationFiles.filter((migration) => applied.has(migration.name)).length,
newlyAppliedCount,
durationMs: Date.now() - startedAt,
},